# Copyright (C) 2019  Frank Sachsenheim
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

from abc import ABC, abstractmethod
from collections.abc import MutableSequence
from copy import deepcopy
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
from typing import IO as IOType

from lxml import etree

from delb import utils
from delb.exceptions import InvalidOperation
from delb.plugins import plugin_manager
from delb.plugins.contrib.core_loaders import tag_node_loader
from delb.nodes import (
    altered_default_filters,
    any_of,
    _get_or_create_element_wrapper,
    is_comment_node,
    is_processing_instruction_node,
    is_root_node,
    is_tag_node,
    _is_tag_or_text_node,
    is_text_node,
    not_,
    new_comment_node,
    new_processing_instruction_node,
    new_tag_node,
    tag,
    CommentNode,
    NodeBase,
    ProcessingInstructionNode,
    TagNode,
    TextNode,
)
from delb.typing import _WrapperCache, Loader


# constants


# care has to be taken:
# https://wiki.tei-c.org/index.php/XML_Whitespace#Recommendations
# https://wiki.tei-c.org/index.php/XML_Whitespace#Default_Whitespace_Processing
# https://lxml.de/FAQ.html#why-doesn-t-the-pretty-print-option-reformat-my-xml-output
DEFAULT_PARSER = etree.XMLParser(remove_blank_text=True)


# api


def first(iterable: Iterable) -> Optional[Any]:
    """
    Returns the first item of the given :term:`iterable` or ``None`` if it's empty.
    Note that the first item is consumed when the iterable is an :term:`iterator`.
    """
    if isinstance(iterable, Iterator):
        try:
            return next(iterable)
        except StopIteration:
            return None
    elif isinstance(iterable, Sequence):
        return iterable[0] if len(iterable) else None
    else:
        raise TypeError


def get_traverser(from_left=True, depth_first=True, from_top=True):
    """
    Returns a function that can be used to traverse a (sub)tree with the given node as
    root. While traversing the given root node is yielded at some point.

    The returned functions have this signature:

    .. code-block:: python

        def traverser(root: NodeBase, *filters: Filter) -> Iterator[NodeBase]:
            ...

    :param from_left: The traverser yields sibling nodes from left to right if ``True``,
                      or starting from the right if ``False``.
    :param depth_first: The child nodes resp. the parent node are yielded before the
                        siblings of a node by a traverser if ``True``. Siblings are
                        favored if ``False``.
    :param from_top: The traverser starts yielding nodes with the lowest depth if
                     ``True``. When ``False``, again, the opposite is in effect.
    """

    result = utils.TRAVERSERS.get((from_left, depth_first, from_top))
    if result is None:
        raise NotImplementedError
    return result


def last(iterable: Iterable) -> Optional[Any]:
    """
    Returns the last item of the given :term:`iterable` or ``None`` if it's empty.
    Note that the whole :term:`iterator` is consumed when such is given.
    """
    if isinstance(iterable, Iterator):
        result = None
        for result in iterable:
            pass
        return result
    elif isinstance(iterable, Sequence):
        return iterable[-1] if len(iterable) else None
    else:
        raise TypeError


def register_namespace(prefix: str, namespace: str):
    """
    Registers a namespace prefix that newly created :class:`TagNode` instances in that
    namespace will use in serializations.

    The registry is global, and any existing mapping for either the given prefix or the
    namespace URI will be removed. It has however no effect on the serialization of
    existing nodes, see :meth:`Document.cleanup_namespace` for that.

    :param prefix: The prefix to register.
    :param namespace: The targeted namespace.
    """
    etree.register_namespace(prefix, namespace)


class _RootSiblingsContainer(ABC, MutableSequence):
    def __init__(self, document: "Document"):
        self._document = document

    def __delitem__(self, index):
        # https://stackoverflow.com/q/54562097/
        raise InvalidOperation(
            "The thing is, lxml doesn't provide an interface to remove siblings of the "
            "root node."
        )

    def __getitem__(self, index):
        # TODO? slices

        if index < 0:
            index = len(self) + index

        if len(self) <= index:
            raise IndexError

        if index == 0:
            return self._get_first()

        with altered_default_filters():
            for i, result in enumerate(self._get_first().iterate_next_nodes(), start=1):
                if i == index:
                    return result

    def __setitem__(self, index, node):
        # https://stackoverflow.com/q/54562097/
        raise InvalidOperation(
            "The thing is, lxml doesn't provide an interface to remove siblings of the "
            "root node."
        )

    def __len__(self):
        index = -1
        for index, _ in enumerate(self._iter_all()):
            pass
        return index + 1

    def insert(self, index, node):
        length = len(self)

        if index == 0 and not length:
            self._add_first(node)
        elif index == length:
            self[-1].add_next(node)
        else:
            self[index].add_previous(node)

    def prepend(self, node):
        self.insert(0, node)

    @abstractmethod
    def _add_first(self, node: NodeBase):
        pass

    @abstractmethod
    def _get_first(self) -> Optional[NodeBase]:
        pass

    @abstractmethod
    def _iter_all(self) -> Iterator["NodeBase"]:
        pass


class _HeadNodes(_RootSiblingsContainer):
    def _add_first(self, node):
        self._document.root.add_previous(node)

    def _get_first(self):
        return last(self._iter_all())

    def _iter_all(self):
        with altered_default_filters():
            yield from self._document.root.iterate_previous_nodes()


class _TailNodes(_RootSiblingsContainer):
    def _add_first(self, node):
        self._document.root.add_next(node)

    def _get_first(self):
        return first(self._iter_all())

    def _iter_all(self):
        with altered_default_filters():
            yield from self._document.root.iterate_next_nodes()


class DocumentExtensionHooks:
    """
    This class acts as termination for methods that can be implemented by extension
    classes. Any implementation of a method must call a base class' one with
    :func:`super`.
    """

    def _init_config(self, config_args: Dict[str, Any]):
        """
        The ``config_args`` contains the additional keyword arguments that a
        :class:`Document` instance is called with. Extension classes that expect
        configuration data *must* process their specific arguments by clearing them
        from the ``config_args`` dictionary, e.g. with :meth:`dict.pop`, and preferably
        storing the final configuration data in a :class:`types.SimpleNamespace` and
        bind it to the instance's :attr:`Document.config` property with the extension's
        name. The initially mentioned keyword arguments *should* be prefixed with that
        name. This method is called before the loaders try to read and parse the given
        source for a document.
        """
        pass


class DocumentMeta(type):
    def __new__(mcs, name, base_classes, namespace):
        extension_classes = []
        for obj in plugin_manager.hook.get_document_extensions():
            if isinstance(obj, Iterable):
                assert all(isinstance(x, type) for x in obj)
                extension_classes.extend(obj)
            elif isinstance(obj, type):
                extension_classes.append(obj)
            else:
                raise TypeError

        if not base_classes:  # Document class is being constructed
            extension_docs = sorted(
                (x.__name__, x.__doc__) for x in extension_classes if x.__doc__
            )
            if extension_docs:
                namespace["__doc__"] += "\n\n" + "\n\n".join(
                    (f"{x[0]}:\n\n{x[1]}" for x in extension_docs)
                )

        base_classes += tuple(extension_classes) + (DocumentExtensionHooks,)

        configured_loaders = []
        plugin_manager.hook.configure_loaders(loaders=configured_loaders)
        namespace["_loaders"] = (tag_node_loader, *configured_loaders)

        return super().__new__(mcs, name, base_classes, namespace)


class Document(metaclass=DocumentMeta):
    """
    This class is the entrypoint to obtain a representation of an XML encoded text
    document. For instantiation any object can be passed. A suitable loader must be
    available for the given source. See :ref:`document-loaders` for the default loaders
    that come with this package. Plugins are capable to alter the available loaders,
    see :doc:`extending`.

    Nodes can be tested for membership in a document:

    >>> document = Document("<root>text</root>")
    >>> text_node = document.root[0]
    >>> text_node in document
    True
    >>> text_node.clone() in document
    False

    The string coercion of a document yields an XML encoded stream, but unlike
    :meth:`Document.save` and :meth:`Document.write`, without an XML declaration:

    >>> document = Document("<root/>")
    >>> str(document)
    '<root/>'

    :param source: Anything that the configured loaders can make sense of to return a
                   parsed document tree.
    :param parser: An optional :class:`lxml.etree.XMLParser` instance that is used to
                   parse a document stream.
    :param config: Additional keyword arguments for the configuration of extension
                   classes.
    """

    _loaders: Tuple[Loader, ...]
    __slots__ = ("__root_node__", "head_nodes", "tail_nodes")

    def __init__(self, source: Any, parser: etree.XMLParser = DEFAULT_PARSER, **config):
        self.config: SimpleNamespace = SimpleNamespace()
        """
        Beside the used ``parser``, this property contains the namespaced data that
        extension classes and loaders may store.
        """
        self.config.parser = parser
        self._init_config(config)  # type: ignore
        if config:
            raise RuntimeError(
                "Not all configuration arguments have been processed. You either "
                "passed invalid arguments or an extension doesn't handle them "
                f"properly: {config}"
            )

        loaded_tree: Optional[etree._ElementTree] = None
        wrapper_cache: Optional[_WrapperCache] = None
        for loader in self._loaders:
            loaded_tree, wrapper_cache = loader(source, self.config)
            if loaded_tree:
                break

        if loaded_tree is None or not isinstance(wrapper_cache, dict):
            raise ValueError(
                f"Couldn't load {source!r} with these currently configured loaders: "
                + ", ".join(x.__name__ for x in self._loaders)
            )

        root = _get_or_create_element_wrapper(loaded_tree.getroot(), wrapper_cache)
        assert isinstance(root, TagNode)
        self.root = root

        self.head_nodes = _HeadNodes(self)
        """
        A list-like accessor to the nodes that precede the document's root node.
        Note that nodes can't be removed or replaced.
        """
        self.tail_nodes = _TailNodes(self)
        """
        A list-like accessor to the nodes that follow the document's root node.
        Note that nodes can't be removed or replaced.
        """

    def __contains__(self, node: NodeBase) -> bool:
        return node.document is self

    def __str__(self):
        clone = self.clone()
        clone.merge_text_nodes()
        return etree.tounicode(clone.root._etree_obj.getroottree())

    def cleanup_namespaces(
        self,
        namespaces: Optional["etree._NSMap"] = None,
        retain_prefixes: Optional[Iterable[str]] = None,
    ):
        """
        Consolidates the namespace declarations in a document by removing unused and
        redundant ones.

        There are currently some caveats due to lxml's implementations:
          - prefixes cannot be set for the default namespace
          - a namespace cannot be declared as default after a node's creation (where a
            namespace was specified that had been registered for a prefix with
            :func:`register_namespace`)
          - there's no way to unregister a prefix for a namespace
          - if there are other namespaces used as default namespaces (where a namespace
            was specified that had *not* been registered for a prefix) in the
            descendants of the root, their declarations are lost when this method is
            used

        To ensure clean serializations, one should:
          - register prefixes for all except the default namespace at the start of an
            application
          - use only one default namespace within a document

        :param namespaces: An optional :term:`mapping` of prefixes (keys) to namespaces
                           (values) that will be declared at the root element.
        :param retain_prefixes: An optional iterable that contains prefixes whose
                                declarations shall be kept despite not being used.
        """
        etree.cleanup_namespaces(
            self.root._etree_obj.getroottree(),
            top_nsmap=namespaces,
            keep_ns_prefixes=retain_prefixes,
        )

    def clone(self) -> "Document":
        """
        :return: Another instance w/ the duplicated contents.
        """
        # lxml.etree.XMLParser instances aren't pickable / copyable
        parser = self.config.__dict__.pop("parser")
        result = self.__class__(self.root, parser=parser)
        result.config = deepcopy(self.config)
        self.config.parser = result.config.parser = parser
        return result

    def css_select(self, expression: str) -> List["TagNode"]:
        """
        This method proxies to the :meth:`TagNode.css_select` method of the document's
        root node.
        """
        return self.root.css_select(expression)

    def merge_text_nodes(self):
        """
        This method proxies to the :meth:`TagNode.merge_text_nodes` method of the
        document's root node.
        """
        self.root.merge_text_nodes()

    @property
    def namespaces(self) -> Dict[str, str]:
        """
        The namespace mapping of the document's root node.
        """
        return self.root.namespaces

    def new_tag_node(
        self,
        local_name: str,
        attributes: Optional[Dict[str, str]] = None,
        namespace: Optional[str] = None,
    ) -> "TagNode":
        """
        This method proxies to the :meth:`TagNode.new_tag_node` method of the
        document's root node.
        """
        return self.root.new_tag_node(
            local_name=local_name, attributes=attributes, namespace=namespace
        )

    @property
    def root(self) -> "TagNode":
        """ The root node of a document tree. """
        return getattr(self, "__root_node__")

    @root.setter
    def root(self, node: "TagNode"):
        if not isinstance(node, TagNode):
            raise TypeError("The document root node must be of 'TagNode' type.")

        with altered_default_filters(_is_tag_or_text_node):
            if not all(
                x is None for x in (node.parent, node.previous_node(), node.next_node())
            ):
                raise InvalidOperation(
                    "Only a detached node can be set as root. Use "
                    ":meth:`TagNode.clone` or :meth:`TagNode.detach` on the "
                    "designated root node."
                )

        current_root = getattr(self, "__root_node__", None)
        if current_root is not None:
            utils.copy_root_siblings(current_root._etree_obj, node._etree_obj)
            delattr(current_root, "__document__")

        setattr(node, "__document__", self)
        setattr(self, "__root_node__", node)

    def save(self, path: Path, pretty: bool = False, **cleanup_namespaces_args):
        """
        :param path: The path where the document shall be saved.
        :param pretty: Adds indentation for human consumers when ``True``.
        :param cleanup_namespaces_args: Arguments that are a passed to
                                        :meth:`Document.cleanup_namespaces` before
                                        saving.
        """
        with path.open("bw") as file:
            self.write(file, pretty=pretty, **cleanup_namespaces_args)

    def write(self, buffer: IOType, pretty: bool = False, **cleanup_namespaces_args):
        """
        :param buffer: An :term:`file-like object` that the document is written to.
        :param pretty: Adds indentation for human consumers when ``True``.
        :param cleanup_namespaces_args: Arguments that are a passed to
                                        :meth:`Document.cleanup_namespaces` before
                                        writing.
        """
        self.root.merge_text_nodes()
        self.cleanup_namespaces(**cleanup_namespaces_args)
        self.root._etree_obj.getroottree().write(
            file=buffer, encoding="utf-8", pretty_print=pretty, xml_declaration=True
        )

    def xpath(self, expression: str) -> List["TagNode"]:
        """
        This method proxies to the :meth:`TagNode.xpath` method of the document's root
        node.
        """

        return self.root.xpath(expression)

    def xslt(self, transformation: etree.XSLT) -> "Document":
        """
        :param transformation: A :class:`lxml.etree.XSLT` instance that shall be
                               applied to the document.
        :return: A new instance with the transformation's result.
        """
        result = transformation(self.root._etree_obj.getroottree())
        return Document(result.getroot())


__all__ = (
    CommentNode.__name__,
    Document.__name__,
    InvalidOperation.__name__,
    ProcessingInstructionNode.__name__,
    TagNode.__name__,
    TextNode.__name__,
    altered_default_filters.__name__,
    any_of.__name__,
    first.__name__,
    get_traverser.__name__,
    is_comment_node.__name__,
    is_processing_instruction_node.__name__,
    is_root_node.__name__,
    is_tag_node.__name__,
    is_text_node.__name__,
    last.__name__,
    not_.__name__,
    new_comment_node.__name__,
    new_tag_node.__name__,
    new_processing_instruction_node.__name__,
    register_namespace.__name__,
    tag.__name__,
)
