"""Sphinx test suite utilities"""
from __future__ import annotations

import contextlib
import os
import re
import sys
import warnings
from typing import IO, TYPE_CHECKING, Any
from xml.etree import ElementTree

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

from sphinx import application, locale
from sphinx.pycode import ModuleAnalyzer

if TYPE_CHECKING:
    from io import StringIO

    from docutils.nodes import Node

    from sphinx.util._pathlib import _StrPath

__all__ = 'SphinxTestApp', 'SphinxTestAppWrapperForSkipBuilding'


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), \
                        'The node%s does not have any children' % xpath
                    assert len(node) == 1, \
                        'The node%s has %d child nodes, not one' % (xpath, len(node))
                    assert_node(node[0], cls[1:], xpath=xpath + "[0]", **kwargs)
        elif isinstance(cls, tuple):
            assert isinstance(node, (list, nodes.Element)), \
                'The node%s does not have any items' % xpath
            assert len(node) == len(cls), \
                'The node%s has %d child nodes, not %r' % (xpath, len(node), len(cls))
            for i, nodecls in enumerate(cls):
                path = xpath + "[%d]" % 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}'

    if kwargs:
        assert isinstance(node, nodes.Element), \
            'The node%s does not have any attributes' % xpath

        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}'


def etree_parse(path: str) -> Any:
    with warnings.catch_warnings(record=False):
        warnings.filterwarnings("ignore", category=DeprecationWarning)
        return ElementTree.parse(path)  # NoQA: S314  # using known data in tests


class SphinxTestApp(application.Sphinx):
    """
    A subclass of :class:`Sphinx` that runs on the test root, with some
    better default values for the initialization parameters.
    """
    _status: StringIO
    _warning: StringIO

    def __init__(
        self,
        buildername: str = 'html',
        srcdir: _StrPath | None = None,
        builddir: _StrPath | None = None,
        freshenv: bool = False,
        confoverrides: dict | None = None,
        status: IO | None = None,
        warning: IO | None = None,
        tags: list[str] | None = None,
        docutilsconf: str | None = None,
        parallel: int = 0,
    ) -> None:
        assert srcdir is not None

        self.docutils_conf_path = srcdir / 'docutils.conf'
        if docutilsconf is not None:
            self.docutils_conf_path.write_text(docutilsconf, 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 = {}
        warningiserror = False

        self._saved_path = sys.path[:]
        self._saved_directives = directives._directives.copy()  # type: ignore[attr-defined]
        self._saved_roles = roles._roles.copy()  # type: ignore[attr-defined]

        self._saved_nodeclasses = {v for v in dir(nodes.GenericNodeVisitor)
                                   if v.startswith('visit_')}

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

    def cleanup(self, doctrees: bool = False) -> None:
        ModuleAnalyzer.cache.clear()
        locale.translators.clear()
        sys.path[:] = self._saved_path
        sys.modules.pop('autodoc_fodder', None)
        directives._directives = self._saved_directives  # type: ignore[attr-defined]
        roles._roles = self._saved_roles  # type: ignore[attr-defined]
        for method in dir(nodes.GenericNodeVisitor):
            if method.startswith('visit_') and \
               method not in self._saved_nodeclasses:
                delattr(nodes.GenericNodeVisitor, 'visit_' + method[6:])
                delattr(nodes.GenericNodeVisitor, 'depart_' + method[6:])
        with contextlib.suppress(FileNotFoundError):
            os.remove(self.docutils_conf_path)

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

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


class SphinxTestAppWrapperForSkipBuilding:
    """
    This class is a wrapper for SphinxTestApp to speed up the test by skipping
    `app.build` process if it is already built and there is even one output
    file.
    """

    def __init__(self, app_: SphinxTestApp) -> None:
        self.app = app_

    def __getattr__(self, name: str) -> Any:
        return getattr(self.app, name)

    def build(self, *args: Any, **kwargs: Any) -> None:
        if not os.listdir(self.app.outdir):
            # if listdir is empty, do build.
            self.app.build(*args, **kwargs)
            # otherwise, we can use built cache


def strip_escseq(text: str) -> str:
    return re.sub('\x1b.*?m', '', text)
