"""Sphinx test suite utilities"""

from __future__ import annotations

__all__ = ('SphinxTestApp', 'SphinxTestAppWrapperForSkipBuilding')

import sys
from io import StringIO
from types import MappingProxyType
from typing import TYPE_CHECKING

from docutils import nodes
from docutils.parsers.rst import directives, roles

import sphinx.application
import sphinx.locale
import sphinx.pycode
from sphinx.util.docutils import additional_nodes

if TYPE_CHECKING:
    import os
    from collections.abc import Mapping, Sequence
    from pathlib import Path
    from typing import Any
    from xml.etree.ElementTree import ElementTree

    from docutils.nodes import Node


def assert_node(node: Node, cls: Any = None, xpath: str = '', **kwargs: Any) -> None:
    if cls:
        if isinstance(cls, list):
            assert_node(node, cls[0], xpath=xpath, **kwargs)
            if cls[1:]:
                if isinstance(cls[1], tuple):
                    assert_node(node, cls[1], xpath=xpath, **kwargs)
                else:
                    assert (
                        isinstance(node, nodes.Element)
                    ), f'The node{xpath} does not have any children'  # fmt: skip
                    assert len(node) == 1, (
                        f'The node{xpath} has {len(node)} child nodes, not one'
                    )
                    assert_node(node[0], cls[1:], xpath=xpath + '[0]', **kwargs)
        elif isinstance(cls, tuple):
            assert (
                isinstance(node, (list, nodes.Element))
            ), f'The node{xpath} does not have any items'  # fmt: skip
            assert (
                len(node) == len(cls)
            ), f'The node{xpath} has {len(node)} child nodes, not {len(cls)!r}'  # fmt: skip
            for i, nodecls in enumerate(cls):
                path = xpath + f'[{i}]'
                assert_node(node[i], nodecls, xpath=path, **kwargs)
        elif isinstance(cls, str):
            assert node == cls, f'The node {xpath!r} is not {cls!r}: {node!r}'
        else:
            assert (
                isinstance(node, cls)
            ), f'The node{xpath} is not subclass of {cls!r}: {node!r}'  # fmt: skip

    if kwargs:
        assert (
            isinstance(node, nodes.Element)
        ), f'The node{xpath} does not have any attributes'  # fmt: skip

        for key, value in kwargs.items():
            if key not in node:
                if (key := key.replace('_', '-')) not in node:
                    msg = f'The node{xpath} does not have {key!r} attribute: {node!r}'
                    raise AssertionError(msg)
            assert node[key] == value, (
                f'The node{xpath}[{key}] is not {value!r}: {node[key]!r}'
            )


# keep this to restrict the API usage and to have a correct return type
def etree_parse(path: str | os.PathLike[str]) -> ElementTree:
    """Parse a file into a (safe) XML element tree."""
    from defusedxml.ElementTree import parse as xml_parse

    return xml_parse(path)


class SphinxTestApp(sphinx.application.Sphinx):
    """A subclass of :class:`~sphinx.application.Sphinx` for tests.

    The constructor uses some better default values for the initialization
    parameters and supports arbitrary keywords stored in the :attr:`extras`
    read-only mapping.

    It is recommended to use::

        @pytest.mark.sphinx('html', testroot='root')
        def test(app):
            app = ...

    instead of::

        def test():
            app = SphinxTestApp('html', srcdir=srcdir)

    In the former case, the 'app' fixture takes care of setting the source
    directory, whereas in the latter, the user must provide it themselves.
    """

    # see https://github.com/sphinx-doc/sphinx/pull/12089 for the
    # discussion on how the signature of this class should be used

    def __init__(
        self,
        /,  # to allow 'self' as an extras
        buildername: str = 'html',
        srcdir: Path | None = None,
        builddir: Path | None = None,  # extra constructor argument
        freshenv: bool = False,  # argument is not in the same order as in the superclass
        confoverrides: dict[str, Any] | None = None,
        status: StringIO | None = None,
        warning: StringIO | None = None,
        tags: Sequence[str] = (),
        docutils_conf: str | None = None,  # extra constructor argument
        parallel: int = 0,
        # additional arguments at the end to keep the signature
        verbosity: int = 0,  # argument is not in the same order as in the superclass
        warningiserror: bool = False,  # argument is not in the same order as in the superclass
        pdb: bool = False,
        exception_on_warning: bool = False,
        # unknown keyword arguments
        **extras: Any,
    ) -> None:
        self._builder_name = buildername

        assert srcdir is not None

        if verbosity == -1:
            quiet = True
            verbosity = 0
        else:
            quiet = False

        if status is None:
            # ensure that :attr:`status` is a StringIO and not sys.stdout
            # but allow the stream to be /dev/null by passing verbosity=-1
            status = None if quiet else StringIO()
        elif not isinstance(status, StringIO):
            err = f"'status' must be an io.StringIO object, got: {type(status)}"
            raise TypeError(err)

        if warning is None:
            # ensure that :attr:`warning` is a StringIO and not sys.stderr
            # but allow the stream to be /dev/null by passing verbosity=-1
            warning = None if quiet else StringIO()
        elif not isinstance(warning, StringIO):
            err = f"'warning' must be an io.StringIO object, got: {type(warning)}"
            raise TypeError(err)

        self.docutils_conf_path = srcdir / 'docutils.conf'
        if docutils_conf is not None:
            self.docutils_conf_path.write_text(docutils_conf, encoding='utf8')

        if builddir is None:
            builddir = srcdir / '_build'

        confdir = srcdir
        outdir = builddir.joinpath(buildername)
        outdir.mkdir(parents=True, exist_ok=True)
        doctreedir = builddir.joinpath('doctrees')
        doctreedir.mkdir(parents=True, exist_ok=True)
        if confoverrides is None:
            confoverrides = {}

        self._saved_path = sys.path.copy()
        self.extras: Mapping[str, Any] = MappingProxyType(extras)
        """Extras keyword arguments."""

        try:
            super().__init__(
                srcdir,
                confdir,
                outdir,
                doctreedir,
                buildername,
                confoverrides=confoverrides,
                status=status,
                warning=warning,
                freshenv=freshenv,
                warningiserror=warningiserror,
                tags=tags,
                verbosity=verbosity,
                parallel=parallel,
                pdb=pdb,
                exception_on_warning=exception_on_warning,
            )
        except Exception:
            self.cleanup()
            raise

    def _init_builder(self) -> None:
        # override the default theme to 'basic' rather than 'alabaster'
        # for test independence

        if 'html_theme' in self.config._overrides:
            pass  # respect overrides
        elif 'html_theme' in self.config and self.config.html_theme == 'alabaster':
            self.config.html_theme = self.config._overrides.get('html_theme', 'basic')
        super()._init_builder()

    @property
    def status(self) -> StringIO:
        """The in-memory text I/O for the application status messages."""
        # sphinx.application.Sphinx uses StringIO for a quiet stream
        assert isinstance(self._status, StringIO)
        return self._status

    @property
    def warning(self) -> StringIO:
        """The in-memory text I/O for the application warning messages."""
        # sphinx.application.Sphinx uses StringIO for a quiet stream
        assert isinstance(self._warning, StringIO)
        return self._warning

    def cleanup(self, doctrees: bool = False) -> None:
        sys.path[:] = self._saved_path
        _clean_up_global_state()
        try:
            self.docutils_conf_path.unlink(missing_ok=True)
        except OSError as exc:
            if exc.errno != 30:  # Ignore "read-only file system" errors
                raise

    def __repr__(self) -> str:
        return f'<{self.__class__.__name__} buildername={self._builder_name!r}>'

    def build(self, force_all: bool = False, filenames: Sequence[Path] = ()) -> None:
        self.env._pickled_doctree_cache.clear()
        super().build(force_all, filenames)


class SphinxTestAppWrapperForSkipBuilding(SphinxTestApp):
    """A wrapper for SphinxTestApp.

    This class is used to speed up the test by skipping ``app.build()``
    if it has already been built and there are any output files.
    """

    def build(self, force_all: bool = False, filenames: Sequence[Path] = ()) -> None:
        if not list(self.outdir.iterdir()):
            # if listdir is empty, do build.
            super().build(force_all, filenames)
            # otherwise, we can use built cache


def _clean_up_global_state() -> None:
    # clean up Docutils global state
    directives._directives.clear()  # type: ignore[attr-defined]
    roles._roles.clear()  # type: ignore[attr-defined]
    for node in additional_nodes:
        delattr(nodes.GenericNodeVisitor, f'visit_{node.__name__}')
        delattr(nodes.GenericNodeVisitor, f'depart_{node.__name__}')
        delattr(nodes.SparseNodeVisitor, f'visit_{node.__name__}')
        delattr(nodes.SparseNodeVisitor, f'depart_{node.__name__}')
    additional_nodes.clear()

    # clean up Sphinx global state
    sphinx.locale.translators.clear()

    # clean up autodoc global state
    sphinx.pycode.ModuleAnalyzer.cache.clear()
