#!/usr/bin/env python3
"""Lightweight NETCONF Controller (LNCC)

Emulates a real SDN Controller by pushing NETCONF operations, ...

License
=======
Software Name : Lightweight NETCONF Controller (LNCC)
Version: 0.3.2
SPDX-FileCopyrightText: Copyright (c) 2022 Jean Rebiffé, Orange Innovation Networks
SPDX-License-Identifier: BSD-3-Clause
SPDX-FileType: SOURCE
SPDX-FileContributor: Jean Rebiffe, Orange Innovation Networks

This software is distributed under the BSD 3-Clause "New" or "Revised" License,
the text of which is available at https://opensource.org/licenses/BSD-3-Clause
or see the "license.txt" file for more details.

Author: Jean Rebiffe, Orange Innovation Networks
Software description: Emulates a real SDN Controller by pushing NETCONF
operations, ...
"""

__version__ = "0.3.2"
__author__ = "Jean Rebiffe, Orange Innovation Networks, 2022"

import argparse
import logging
import logging.config
import sys
from collections import ChainMap, UserDict
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple

import cmd2
import ncclient
import pandas
import yaml
from ncclient import manager

LOGGER = logging.getLogger("lncc")

# https://datatracker.ietf.org/doc/html/rfc8342#section-5.1
DATASTORES = {"running", "candidate", "startup", "intended"}


class LnccError(Exception):
    ...


class TemplateError(LnccError):
    """Error when performing a XML templating.

    May appear on `NetworkElement.build_config()` for 2 reasons:
    - the mapping parameters include inexistant mapping name
    - the XML template contains a variable which doesnt appears in mappings
    Typically during <edit-config> operation
    command: `netconf edit-config` with `--template-config` and
    `--mapping` options.
    """


@dataclass
class NetworkElement:
    """Represent a single Network Element managable with NETCONF

    This `@dataclass` essencially store data and implement few methods

    >>> my_ne = NetworkElement("my_ne", {"host": "192.0.2.1",
                                         "username": "john.doe"
                                         "password": "RmDuU7-bg57h7R*Y5f"})
    >>> my_ne.connect()
    """

    name: str
    params: Dict[str, str] = field(compare=False)
    conn: Optional[manager.Manager] = field(default=None, compare=False)
    replies: Dict[str, ncclient.operations.retrieve.GetReply] = field(
        default_factory=dict, compare=False)
    tables: Dict[str, pandas.DataFrame] = field(default_factory=dict,
                                                compare=False)
    configs: Dict[str, str] = field(default_factory=dict, compare=False)
    mappings: Dict[str, Dict[str, str]] = field(default_factory=dict,
                                                compare=False)

    def to_dict(self) -> Dict[str, Any]:
        dic: Dict[str, Any] = {"params": self.params}
        if self.mappings:
            dic["mappings"] = self.mappings
        return dic

    def connect(self) -> manager.Manager:
        """Initiate NETCONF connection"""
        LOGGER.debug("Start: NETCONF connect() to %s", self.name)
        try:
            self.conn = manager.connect_ssh(**self.params,
                                            hostkey_verify=False)
            # TODO(Jean): fix vendor-specific hook - needed on my lab
            # self.conn = manager.connect_ssh(**self.params,
            #                                device_params={
            #                                    "name": "huaweiyang"
            #                                })
        except ncclient.NCClientError as err:
            LOGGER.warning("Failed: NETCONF connect() to %s: Reason: %s",
                           self.name, err)
            raise err

        LOGGER.debug("Success: NETCONF connect() to %s", self.name)

        return self.conn

    def netconf_operation(self,
                          operation: str,
                          reply: Optional[str] = None,
                          **kwargs) -> None:
        if not reply:
            reply = operation

        if "config_name" in kwargs:
            kwargs = kwargs.copy()
            kwargs["config"] = self.configs[kwargs["config_name"]]
            del kwargs["config_name"]

        func = getattr(self.conn, operation.replace("-", "_"))

        LOGGER.debug(
            "Start: NETCONF <%s> on %s, args: %s",
            operation,
            self.name,
            list(kwargs.keys()),
        )

        try:
            self.replies[reply] = func(**kwargs)
            # print(self.replies[reply].data_ele)
            # print(vars(self.replies[reply].data_ele))
        except ncclient.NCClientError as err:
            LOGGER.warning(
                "Failed: NETCONF <%s> on %s. Reason: %s",
                operation,
                self.name,
                err,
            )
            raise err

        LOGGER.debug("Success: NETCONF <%s> on %s", operation, self.name)

    def build_config(
        self,
        config_template: str,
        mappings: Set[str],
        group_mappings: Dict[str, Dict[str, str]],
    ) -> None:
        assert isinstance(config_template, str)

        # avails = self.mappings.copy()
        # avails.update(self.mappings)
        # unknwon = set(filter(lambda mapp: mapp not in avails, mappings))
        # if unknwon:
        #    raise KeyError(f"Unknwon mapping {unknwon} for NE {self.name} "
        #                   f"Available mappings: {set(avails)}")
        # usables: dict = dict(filter(lambda a: a[0] in mappings,
        #                            avails.items()))
        # mapping = dict(collections.ChainMap(*usables.values()))

        # TODO(jean): To use all mapping by default
        maps = ChainMap(self.mappings, group_mappings)

        if not mappings.issubset(maps):
            # TODO(Jean): build own Exception?
            raise TemplateError(f"Unknwon mapping {mappings.difference(maps)} "
                                f"for NE {self.name}. "
                                f"Available mappings: {set(maps)}")

        usables = [maps[key] for key in mappings]
        fmtmap = ChainMap(*usables)

        try:
            self.configs["default"] = config_template.format_map(fmtmap)
        except KeyError as err:
            raise TemplateError(
                f"Cannot build template, {err} missing for NE {self.name}"
            ) from err

    def table_from_xml(
        self,
        table: str = "get",
        reply: Optional[str] = None,
        xsl_transform: Optional[str] = None,
    ) -> None:
        """Build a panda DataFrame, based on previous XML ouput"""
        if reply is None:
            reply = table

        self.tables[table] = pandas.read_xml(self.replies[reply].data_xml,
                                             stylesheet=xsl_transform)


class NetworkElementGroup(UserDict):
    """Represent a Group of Network Element"""

    # TODO(Jean): Correct static dict in class declaration
    tables: Dict[str, pandas.DataFrame] = {}
    mappings: Dict[str, Dict[str, str]] = {}

    @staticmethod
    def from_dict(dic: Dict[str, Any]) -> "NetworkElementGroup":
        """Build dict, that can be dumped in a file (YAML/JSON/...)"""
        assert "nes" in dic

        for name, n_e in dic["nes"].items():
            if not isinstance(n_e, dict) or "params" not in n_e:
                raise TypeError(f"Bad config for {name}")

        group = NetworkElementGroup()

        for name, n_e in dic["nes"].items():
            group[name] = NetworkElement(name, n_e["params"])
            if "mappings" in n_e:
                group[name]["mappings"] = n_e["mappings"]

        if "mappings" in dic:
            group.mappings = dic["mapping"]

        return group

    def to_dict(self) -> Dict[str, Dict[str, Any]]:
        """Build dict, that can be loaded from a file (YAML/JSON/...)"""
        dic: Dict[str, Dict[str, Any]] = {"nes": {}}
        if self.mappings:
            dic["mappings"] = self.mappings
        for name, n_e in self.items():
            dic["nes"][name] = n_e.to_dict()
        return dic

    def connect(self) -> "NetworkElementGroup":
        """Initiate connections to all NetworkElement of the group"""

        def is_unconnected(n_e: Tuple[str, NetworkElement]) -> bool:
            return not (n_e[1].conn and n_e[1].conn.connected)

        unconnected = NetworkElementGroup(filter(is_unconnected, self.items()))

        conns = dict(
            map(lambda n_e: (n_e[0], n_e[1].connect()), unconnected.items()))
        for name in conns:
            self[name].conn = conns[name]

        assert all(n_e.conn for n_e in self.values())
        assert all(n_e.conn.connected for n_e in self.values())
        # assert all(filter(lambda n_e: n_e.conn.connected, self.values()))

        return unconnected

    def netconf_operation(self, **kwargs) -> None:
        for n_e in self.values():
            n_e.netconf_operation(**kwargs)

    def build_config(self, config_template: str, mappings: Set[str]) -> None:
        for n_e in self.values():
            n_e.build_config(config_template, mappings, self.mappings)

    def table_from_xml(
        self,
        table: str = "get",
        reply: Optional[str] = None,
        xsl_transform: Optional[str] = None,
    ) -> None:
        for n_e in self.data.values():
            n_e.table_from_xml(table, reply, xsl_transform)

    def table_concat(self, table: str = "get") -> pandas.DataFrame:
        # TODO(Jean): Refactoring needed?
        tables = []
        for name, state in self.items():
            tmp_tb = state.tables[table].copy()
            tmp_tb["origin"] = name
            tables.append(tmp_tb)

        self.tables[table] = pandas.concat(tables, ignore_index=True)

        return self.tables[table]

    def table_to_excel(self,
                       excel_writer: str = "get.xlsx",
                       tables: Optional[List[str]] = None) -> None:
        if not tables:
            tables = ["get"]

        with pandas.ExcelWriter(excel_writer) as writer:
            for table in tables:
                self.tables[table].to_excel(writer, sheet_name=table)


class LnccCli(cmd2.Cmd):
    """Lightweight NETCONF Controller command-line interface

    The LNNC command-line interface is build around "cmd2" package

    >>> import sys
    >>> app = LnccCli()
    >>> sys.exit(app.cmdloop())
    """

    nes: NetworkElementGroup = NetworkElementGroup()
    tables: Dict[str, pandas.DataFrame] = {}
    logging_config = None

    intro = ("Welcome to the Lightweight NETCONF Controller lncc.py\n"
             "Type 'help' for more information")

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        logg = cmd2.Settable(
            name="logging_config",
            val_type=argparse.FileType("r"),
            description="YAML logging configuration file",
            settable_object=self,
            onchange_cb=self._onchange_logging,
        )
        self.add_settable(logg)

    @staticmethod
    def _onchange_logging(param_name, old, new) -> None:
        del param_name, old  # Unused
        # import io
        # assert isinstance(new, io.TextIOWrapper)

        with new:
            logging_config = yaml.safe_load(new)
        logging.config.dictConfig(logging_config)

    # 'ne' command and subcommands

    def ne_add(self, args: argparse.Namespace) -> None:
        assert hasattr(args, "host"), f"Missing host in args: {args}"
        assert hasattr(args, "name"), f"Missing name in args: {args}"
        assert hasattr(args, "username"), f"Missing username in args: {args}"

        if args.name in self.nes:
            self.perror(f"Error: NE {args.name} already exist")
            return

        params = {"host": args.host}

        if args.username:
            params["username"] = args.username
        if args.password:
            params["password"] = args.password
        if args.port:
            params["port"] = args.port
        if args.rsa_key_file:
            params["key_filename"] = args.rsa_key_file
        # Workaround for namespace issue on <reply xmlnc:nc="...">
        if args.device_params == "huaweiyang":
            params["device_params"] = {"name": "huaweiyang"}

        n_e = NetworkElement(args.name, params)
        self.nes[args.name] = n_e

        self.pfeedback(f"NE {n_e.name} added, current: {len(self.nes)} NEs")

    def ne_delete(self, args: argparse.Namespace) -> None:
        assert hasattr(args, "name")
        unknown = list(filter(lambda name: name not in self.nes, args.name))
        if unknown:
            self.perror(f"Error: unexisting NEs: '{unknown}'")
            return

        to_del = dict(filter(lambda n_e: n_e[0] in args.name,
                             self.nes.items()))

        unclosed = dict(
            filter(lambda n_e: n_e[1].conn and n_e[1].conn.closed,
                   to_del.items()))

        if unclosed:
            self.perror(f"Error: unclosed NEs: {list(unclosed.keys())}")
            return

        for name in args.name:
            del self.nes[name]

        self.pfeedback(f"Deleted {len(args.name)} NEs: {args.name}, "
                       f"current: {len(self.nes)} NEs")

    def ne_load(self, args: argparse.Namespace) -> None:
        assert hasattr(args, "file")

        with args.file:
            nes_param = yaml.safe_load(args.file)

        # After some checks, let's do the job
        news = NetworkElementGroup.from_dict(nes_param)

        existing_nes = news.keys() & self.nes.keys()
        if existing_nes:
            self.perror(f"Error: Existing NE: {existing_nes}")
            return
        self.nes.update(news)

        self.pfeedback("Loading complete: "
                       f"{len(news)} new NEs: {list(news.keys())}")

    def ne_dump(self, args: argparse.Namespace) -> None:
        nes_dict = self.nes.to_dict()

        if args.file:
            with args.file:
                yaml.safe_dump(nes_dict, stream=args.file)
        else:
            self.poutput(yaml.safe_dump(nes_dict))

    def ne_print(self, args: argparse.Namespace) -> None:
        assert args.name
        tables = list(self.nes[args.name].tables.keys())
        configs = list(self.nes[args.name].configs.keys())
        mappings = list(self.nes[args.name].mappings.keys())
        replies = list(self.nes[args.name].replies.keys())
        self.poutput(f"- {args.name} tables: {tables}")
        self.poutput(f"- {args.name} configs: {configs}")
        self.poutput(f"- {args.name} mappings: {mappings}")
        self.poutput(f"- {args.name} XML replies: {replies}")
        if args.reply_name:
            self.poutput(self.nes[args.name].replies[args.reply_name])

    ne_parser = cmd2.Cmd2ArgumentParser()
    _ne_subparsers = ne_parser.add_subparsers(title="subcommands")

    _ne_add_parser = _ne_subparsers.add_parser("add")
    _ne_add_parser.add_argument("name")

    _ne_add_parser.add_argument("--host", required=True)
    _ne_add_parser.add_argument("--username")
    _ne_add_parser.add_argument("--password")
    _ne_add_parser.add_argument("--port")
    _ne_add_parser.add_argument("--rsa_key_file")
    # Workaround for namespace issue
    _ne_add_parser.add_argument("--device_params", choices=["huaweiyang"])
    _ne_add_parser.set_defaults(func=ne_add)

    _ne_delete_parser = _ne_subparsers.add_parser("delete")
    _ne_delete_parser.add_argument("name", nargs="+", choices=nes)
    _ne_delete_parser.set_defaults(func=ne_delete)

    _ne_load_parser = _ne_subparsers.add_parser("load")
    _ne_load_parser.add_argument("file", type=argparse.FileType("r"))
    _ne_load_parser.set_defaults(func=ne_load)

    _ne_dump_parser = _ne_subparsers.add_parser("dump")
    _ne_dump_parser.add_argument("file",
                                 nargs="?",
                                 type=argparse.FileType("w"))
    _ne_dump_parser.set_defaults(func=ne_dump)

    _ne_print_parser = _ne_subparsers.add_parser("print")
    _ne_print_parser.add_argument("name", choices=nes)
    _ne_print_parser.add_argument("--reply-name")
    _ne_print_parser.set_defaults(func=ne_print)

    @cmd2.with_argparser(ne_parser)
    @cmd2.with_category("Parameter management")
    def do_ne(self, args: argparse.Namespace) -> None:
        if not hasattr(args, "func"):
            self.do_help("netconf")
            return

        args.func(self, args)

    # Mapping command and subcommands

    def mapping_add(self, args: argparse.Namespace) -> None:
        assert hasattr(args, "name")
        assert hasattr(args, "mapping")
        assert hasattr(args, "ne")

        try:
            maps = yaml.safe_load(args.mapping)
        except yaml.YAMLError as err:
            self.perror(f"Error: mapping is not YAML: {err}")
            return

        if not isinstance(maps, dict):
            self.perror(f"Error: mapping is not a dict: {maps}")
            return

        bad_k = filter(lambda mapp: not isinstance(mapp, str), maps.keys())
        # bad_v = filter(lambda mapp: not isinstance(mapp, str), maps.values())
        not_string = list(bad_k)  # + list(bad_v)
        if not_string:
            self.perror(f"Error: not string: {not_string}")

        if args.ne:
            self.nes[args.ne].mappings[args.name] = maps
        else:
            self.nes.mappings[args.name] = maps

    def mapping_print(self, args: argparse.Namespace) -> None:
        del args  # Unued
        self.poutput(f"Group mapping: {self.nes.mappings}")
        for name, n_e in self.nes.items():
            self.poutput(f"NE {name} mapping: {n_e.mappings}")

    mapping_parser = cmd2.Cmd2ArgumentParser()
    _mapping_subparsers = mapping_parser.add_subparsers(title="subcommands")

    _mapping_add_parser = _mapping_subparsers.add_parser("add")
    _mapping_add_parser.add_argument("name")
    _mapping_add_parser.add_argument("mapping", metavar="YAML-MAPPING")
    _mapping_add_parser.add_argument("--ne")
    _mapping_add_parser.set_defaults(func=mapping_add)

    _mapping_print_parser = _mapping_subparsers.add_parser("print")
    _mapping_print_parser.set_defaults(func=mapping_print)

    @cmd2.with_argparser(mapping_parser)
    @cmd2.with_category("Parameter management")
    def do_mapping(self, args: argparse.Namespace) -> None:
        if not hasattr(args, "func"):
            self.do_help("mapping")
            return

        args.func(self, args)

    # connect command

    connect_parser = cmd2.Cmd2ArgumentParser(description="Connect to NEs")

    @cmd2.with_argparser(connect_parser)
    @cmd2.with_category("NETCONF")
    def do_connect(self, args: argparse.Namespace) -> None:
        del args  # Unused
        new = self.nes.connect()
        self.pfeedback(f"{len(new)} new connections, {len(self.nes)} total: "
                       f"{list(self.nes.keys())}")

    # 'netconf' command and subcommands

    netconf_parser = cmd2.Cmd2ArgumentParser(
        description="Run NETCONF operations on NEs")
    netconf_parser.add_argument("--reply", metavar="REPLY-NAME")

    _netconf_subparsers = netconf_parser.add_subparsers(
        title="NETCONF operations")

    _nc_get_parser = _netconf_subparsers.add_parser("get")
    _nc_get_parser.add_argument("--filter-file",
                                metavar="XML-FILE",
                                type=argparse.FileType("r"))
    _nc_get_parser.set_defaults(operation="get")

    _nc_get_config_parser = _netconf_subparsers.add_parser("get-config")
    _nc_get_config_parser.add_argument("--filter-file",
                                       metavar="XML-FILE",
                                       type=argparse.FileType("r"))
    _nc_get_config_parser.add_argument("--source", required=True, choices=DATASTORES)
    _nc_get_config_parser.set_defaults(operation="get-config")

    _nc_edit_parser = _netconf_subparsers.add_parser("edit-config")
    _nc_edit_parser.add_argument(
        "--config-template",
        required=True,
        metavar="TEMPLATE-FILE",
        type=argparse.FileType("r"),
    )
    _nc_edit_parser.add_argument("--mappings",
                                 metavar="MAPPING-NAME",
                                 nargs="*",
                                 required=True)
    _nc_edit_parser.add_argument("--format")
    _nc_edit_parser.add_argument(
        "--target",
        choices=DATASTORES,
        help="Name of the configuration datastore being edited",
    )
    _nc_edit_parser.add_argument("--default-operation",
                                 choices={"merge", "replace", "none"})
    _nc_edit_parser.add_argument(
        "--test-option",
        choices={"test-then-set", "set", "test-only"},
        help="NE checks a complete configuration for syntactical "
        "and semantic errors before applying the configuration",
    )
    _nc_edit_parser.add_argument(
        "--error-option",
        choices={"stop-on-error", "continue-on-error", "rollback-on-error"},
    )
    _nc_edit_parser.set_defaults(operation="edit-config")

    _nc_copy_parser = _netconf_subparsers.add_parser("copy-config")
    _nc_copy_parser.add_argument("--target", required=True, choices=DATASTORES)
    _nc_copy_parser.add_argument("--source", required=True, choices=DATASTORES)
    _nc_copy_parser.set_defaults(operation="copy-config")

    _nc_delete_parser = _netconf_subparsers.add_parser("delete-config")
    _nc_delete_parser.add_argument("--target",
                                   required=True,
                                   choices=DATASTORES)
    _nc_delete_parser.set_defaults(operation="delete-config")

    _nc_lock_parser = _netconf_subparsers.add_parser("lock")
    _nc_lock_parser.add_argument("--target", choices=DATASTORES)
    _nc_lock_parser.set_defaults(operation="lock")

    _nc_unlock_parser = _netconf_subparsers.add_parser("unlock")
    _nc_unlock_parser.add_argument("--target", choices=DATASTORES)
    _nc_unlock_parser.set_defaults(operation="unlock")

    _nc_close_parser = _netconf_subparsers.add_parser("close-session")
    _nc_close_parser.set_defaults(operation="close-session")

    _nc_kill_parser = _netconf_subparsers.add_parser("kill-session")
    _nc_kill_parser.add_argument("--session-id", required=True)
    _nc_kill_parser.set_defaults(operation="kill-session")

    _nc_commit_parser = _netconf_subparsers.add_parser("commit")
    _nc_commit_parser.add_argument("--confirmed", action="store_true")
    # argparse.BooleanOptionalAction)
    _nc_commit_parser.add_argument("--timeout", type=int)
    _nc_commit_parser.add_argument("--persist")
    _nc_commit_parser.add_argument("--persist-id")
    _nc_commit_parser.set_defaults(operation="commit")

    _nc_discard_parser = _netconf_subparsers.add_parser("discard-changes")
    _nc_discard_parser.set_defaults(operation="discard-changes")

    _nc_cancel_commit_parser = _netconf_subparsers.add_parser("cancel-commit")
    _nc_cancel_commit_parser.add_argument("--persist-id")
    _nc_cancel_commit_parser.set_defaults(operation="cancel-commit")

    _nc_validate_parser = _netconf_subparsers.add_parser("validate")
    _nc_validate_parser.add_argument("--target", choices=DATASTORES)
    _nc_validate_parser.set_defaults(operation="validate")

    @cmd2.with_argparser(netconf_parser)
    @cmd2.with_category("NETCONF")
    def do_netconf(self, args: argparse.Namespace) -> None:
        if not hasattr(args, "operation"):
            self.do_help("netconf")
            return

        items1 = vars(args).items()
        items2 = filter(lambda item: not item[0].startswith("cmd2_"), items1)
        items3 = filter(lambda item: item[1] is not None, items2)
        kwargs = dict(items3)

        if "reply" not in kwargs:
            kwargs["reply"] = kwargs["operation"]

        if "filter_file" in kwargs:
            with args.filter_file:
                kwargs["filter"] = ("subtree", args.filter_file.read())
            del kwargs["filter_file"]

        if "config_template" in kwargs:
            assert_msg = "mapping attribute should be if config-template is"
            assert "mappings" in kwargs, assert_msg
            with kwargs["config_template"]:
                template = args.config_template.read()
            self.nes.build_config(template, set(args.mappings))
            kwargs["config_name"] = "default"
            del kwargs["mappings"]
            del kwargs["config_template"]

        assert "operation" in kwargs

        self.nes.netconf_operation(**kwargs)

        self.pfeedback(f"NETCONF <{args.operation}> operation completed: "
                       f"{len(self.nes)} NEs, stored in '{kwargs['reply']}'")

    # table command and subcommands

    def table_from_xml(self, args: argparse.Namespace) -> None:
        assert hasattr(args, "table")
        assert args.table

        kwargs = {"table": args.table}

        kwargs["reply"] = args.reply or args.table

        if args.xsl_transform:
            with args.xsl_transform:
                kwargs["xsl_transform"] = args.xsl_transform.read()

        self.nes.table_from_xml(**kwargs)
        self.pfeedback(f"New tables '{args.table}' in each NE, build from "
                       f"XML stored in '{kwargs['reply']}'")

    def table_concat(self, args: argparse.Namespace) -> None:
        assert args.table

        self.nes.table_concat(table=args.table)
        self.pfeedback(f"Table '{args.table}' available for group")

    def table_to_excel(self, args: argparse.Namespace) -> None:
        assert args.tables
        assert args.file

        with args.file:
            self.nes.table_to_excel(args.file, args.tables)
        self.pfeedback(f"Wrote Excel file {args.file.name}, "
                       f"with {len(args.tables)} tabs: {args.tables}")

    table_parser = cmd2.Cmd2ArgumentParser()
    _table_subparsers = table_parser.add_subparsers(title="subcommands")

    _tb_xml_parser = _table_subparsers.add_parser("from_xml")
    _tb_xml_parser.add_argument("--table", default="get")
    _tb_xml_parser.add_argument("--reply", metavar="REPLY-NAME")
    _tb_xml_parser.add_argument("--xsl-transform", type=argparse.FileType("r"))
    _tb_xml_parser.set_defaults(func=table_from_xml)

    _tb_concat_parser = _table_subparsers.add_parser("concat")
    _tb_concat_parser.add_argument("--table", default="get")
    _tb_concat_parser.set_defaults(func=table_concat)

    _tb_to_excel_parser = _table_subparsers.add_parser("to_excel")
    _tb_to_excel_parser.add_argument("file",
                                     metavar="EXCEL-FILE",
                                     type=argparse.FileType("wb"))
    _tb_to_excel_parser.add_argument("--tables", default=["get"], nargs="+")
    _tb_to_excel_parser.set_defaults(func=table_to_excel)

    @cmd2.with_argparser(table_parser)
    @cmd2.with_category("Input & output handling")
    def do_table(self, args: argparse.Namespace) -> None:
        if not hasattr(args, "func"):
            self.do_help("table")
            return

        args.func(self, args)

    sleep_parser = cmd2.Cmd2ArgumentParser()
    sleep_parser.add_argument("seconds", type=int)

    @cmd2.with_argparser(sleep_parser)
    @cmd2.with_category("Input & output handling")
    def do_sleep(self, args: argparse.Namespace) -> None:
        import time

        self.pfeedback(f"Sleeping for {args.seconds} seconds")
        time.sleep(args.seconds)

    write_parser = cmd2.Cmd2ArgumentParser()
    write_parser.add_argument("--table", default="get")
    write_parser.add_argument("--reply", metavar="REPLY-NAME")
    write_parser.add_argument("--xsl-transform", type=argparse.FileType("r"))
    write_parser.add_argument("--file",
                              metavar="EXCEL-FILE",
                              type=argparse.FileType("wb"))

    @cmd2.with_argparser(write_parser)
    @cmd2.with_category("Input & output handling")
    def do_write(self, args: argparse.Namespace):
        assert hasattr(args, "table")
        assert args.table

        kwargs = {"table": args.table}

        kwargs["reply"] = args.reply or args.table

        if args.xsl_transform:
            with args.xsl_transform:
                kwargs["xsl_transform"] = args.xsl_transform.read()

        self.nes.table_from_xml(**kwargs)

        try:
            self.nes.table_concat(table=args.table)
        except ValueError as err:
            self.perror(f"Cannot concat tables {err}")
            self.pwarning("Did you forget the --xsl-transform?")
            return

        if args.file:
            with args.file:
                self.nes.table_to_excel(args.file, [args.table])
                self.pfeedback(f"Wrote Excel file {args.file.name}, "
                               f"with tab: {args.table}")
        else:
            self.poutput(self.nes.tables[args.table])
            self.pfeedback(f"Displayed {kwargs['reply']}")


def main() -> None:
    app = LnccCli(persistent_history_file=".lncc-history.json.xz",
                  startup_script=".lnccrc.txt")
    sys.exit(app.cmdloop())


if __name__ == "__main__":
    main()
