from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING

from docutils import nodes
from docutils.statemachine import StringList
from docutils.utils import assemble_option_dict

from sphinx.ext.autodoc import Options
from sphinx.util import logging
from sphinx.util.docutils import SphinxDirective, switch_source_input
from sphinx.util.parsing import nested_parse_to_nodes

if TYPE_CHECKING:
    from typing import Any

    from docutils.nodes import Node
    from docutils.parsers.rst.states import RSTState
    from docutils.utils import Reporter

    from sphinx.config import Config
    from sphinx.environment import BuildEnvironment
    from sphinx.ext.autodoc import Documenter

logger = logging.getLogger(__name__)


# common option names for autodoc directives
AUTODOC_DEFAULT_OPTIONS = [
    'members',
    'undoc-members',
    'no-index',
    'no-index-entry',
    'inherited-members',
    'show-inheritance',
    'private-members',
    'special-members',
    'ignore-module-all',
    'exclude-members',
    'member-order',
    'imported-members',
    'class-doc-from',
    'no-value',
]

AUTODOC_EXTENDABLE_OPTIONS = frozenset({
    'members',
    'private-members',
    'special-members',
    'exclude-members',
})


class DummyOptionSpec(dict[str, Callable[[str], str]]):  # NoQA: FURB189
    """An option_spec allows any options."""

    def __bool__(self) -> bool:
        """Behaves like some options are defined."""
        return True

    def __getitem__(self, _key: str) -> Callable[[str], str]:
        return lambda x: x


class DocumenterBridge:
    """A parameters container for Documenters."""

    def __init__(
        self,
        env: BuildEnvironment,
        reporter: Reporter | None,
        options: Options,
        lineno: int,
        state: Any,
    ) -> None:
        self.env = env
        self._reporter = reporter
        self.genopt = options
        self.lineno = lineno
        self.record_dependencies: set[str] = set()
        self.result = StringList()
        self.state = state


def process_documenter_options(
    documenter: type[Documenter], config: Config, options: dict[str, str]
) -> Options:
    """Recognize options of Documenter from user input."""
    default_options = config.autodoc_default_options
    for name in AUTODOC_DEFAULT_OPTIONS:
        if name not in documenter.option_spec:
            continue
        negated = options.pop('no-' + name, True) is None
        if name in default_options and not negated:
            if name in options and isinstance(default_options[name], str):
                # take value from options if present or extend it
                # with autodoc_default_options if necessary
                if name in AUTODOC_EXTENDABLE_OPTIONS:
                    if options[name] is not None and options[name].startswith('+'):
                        options[name] = f'{default_options[name]},{options[name][1:]}'
            else:
                options[name] = default_options[name]

        elif options.get(name) is not None:
            # remove '+' from option argument if there's nothing to merge it with
            options[name] = options[name].lstrip('+')

    return Options(assemble_option_dict(options.items(), documenter.option_spec))


def parse_generated_content(
    state: RSTState, content: StringList, documenter: Documenter
) -> list[Node]:
    """Parse an item of content generated by Documenter."""
    with switch_source_input(state, content):
        if documenter.titles_allowed:
            return nested_parse_to_nodes(state, content)

        node = nodes.paragraph()
        # necessary so that the child nodes get the right source/line set
        node.document = state.document
        state.nested_parse(content, 0, node, match_titles=False)
        return node.children


class AutodocDirective(SphinxDirective):
    """A directive class for all autodoc directives. It works as a dispatcher of Documenters.

    It invokes a Documenter upon running. After the processing, it parses and returns
    the content generated by Documenter.
    """

    option_spec = DummyOptionSpec()
    has_content = True
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = True

    def run(self) -> list[Node]:
        reporter = self.state.document.reporter

        try:
            source, lineno = reporter.get_source_and_line(  # type: ignore[attr-defined]
                self.lineno
            )
        except AttributeError:
            source, lineno = (None, None)
        logger.debug('[autodoc] %s:%s: input:\n%s', source, lineno, self.block_text)

        # look up target Documenter
        objtype = self.name[4:]  # strip prefix (auto-).
        doccls = self.env._registry.documenters[objtype]

        # process the options with the selected documenter's option_spec
        try:
            documenter_options = process_documenter_options(
                doccls, self.config, self.options
            )
        except (KeyError, ValueError, TypeError) as exc:
            # an option is either unknown or has a wrong type
            logger.error(  # NoQA: TRY400
                'An option to %s is either unknown or has an invalid value: %s',
                self.name,
                exc,
                location=(self.env.docname, lineno),
            )
            return []

        # generate the output
        params = DocumenterBridge(
            self.env, reporter, documenter_options, lineno, self.state
        )
        documenter = doccls(params, self.arguments[0])
        documenter.generate(more_content=self.content)
        if not params.result:
            return []

        logger.debug('[autodoc] output:\n%s', '\n'.join(params.result))

        # record all filenames as dependencies -- this will at least
        # partially make automatic invalidation possible
        for fn in params.record_dependencies:
            self.state.document.settings.record_dependencies.add(fn)

        result = parse_generated_content(self.state, params.result, documenter)
        return result
