"""The Python domain."""

from __future__ import annotations

import builtins
import inspect
import typing
from types import NoneType
from typing import TYPE_CHECKING, NamedTuple, cast

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

from sphinx import addnodes
from sphinx.domains import Domain, Index, IndexEntry, ObjType
from sphinx.domains.python._annotations import _parse_annotation
from sphinx.domains.python._object import PyObject
from sphinx.locale import _, __
from sphinx.roles import XRefRole
from sphinx.util import logging
from sphinx.util.docutils import SphinxDirective
from sphinx.util.nodes import (
    find_pending_xref_condition,
    make_id,
    make_refnode,
)

if TYPE_CHECKING:
    from collections.abc import Iterable, Iterator, Sequence, Set
    from typing import Any, ClassVar

    from docutils.nodes import Element, Node, TextElement

    from sphinx.addnodes import desc_signature, pending_xref
    from sphinx.application import Sphinx
    from sphinx.builders import Builder
    from sphinx.environment import BuildEnvironment
    from sphinx.util.typing import ExtensionMetadata, OptionSpec

# re-export objects for backwards compatibility
# See: https://github.com/sphinx-doc/sphinx/issues/12295

from sphinx.domains.python._annotations import (  # NoQA: F401
    _parse_arglist,  # for sphinx-immaterial
    type_to_xref,
)
from sphinx.domains.python._object import (  # NoQA: F401
    PyField,
    PyGroupedField,
    PyTypedField,
    PyXrefMixin,
    py_sig_re,
)

logger = logging.getLogger(__name__)

pairindextypes = {
    'module': 'module',
    'keyword': 'keyword',
    'operator': 'operator',
    'object': 'object',
    'exception': 'exception',
    'statement': 'statement',
    'builtin': 'built-in function',
}


class ObjectEntry(NamedTuple):
    docname: str
    node_id: str
    objtype: str
    aliased: bool


class ModuleEntry(NamedTuple):
    docname: str
    node_id: str
    synopsis: str
    platform: str
    deprecated: bool


class PyFunction(PyObject):
    """Description of a function."""

    option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy()
    option_spec.update({
        'async': directives.flag,
    })

    def get_signature_prefix(self, sig: str) -> Sequence[nodes.Node]:
        prefix: list[addnodes.desc_sig_element] = []
        if 'async' in self.options:
            prefix.extend((
                addnodes.desc_sig_keyword('', 'async'),
                addnodes.desc_sig_space(),
            ))
        return prefix

    def needs_arglist(self) -> bool:
        return True

    def add_target_and_index(
        self, name_cls: tuple[str, str], sig: str, signode: desc_signature
    ) -> None:
        super().add_target_and_index(name_cls, sig, signode)
        if 'no-index-entry' not in self.options:
            modname = self.options.get('module', self.env.ref_context.get('py:module'))
            node_id = signode['ids'][0]

            name, cls = name_cls
            if modname:
                text = _('%s() (in module %s)') % (name, modname)
                self.indexnode['entries'].append(('single', text, node_id, '', None))
            else:
                text = f'built-in function; {name}()'
                self.indexnode['entries'].append(('pair', text, node_id, '', None))

    def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str:
        # add index in own add_target_and_index() instead.
        return ''


class PyDecoratorFunction(PyFunction):
    """Description of a decorator."""

    def run(self) -> list[Node]:
        # a decorator function is a function after all
        self.name = 'py:function'
        return super().run()

    def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]:
        ret = super().handle_signature(sig, signode)
        signode.insert(0, addnodes.desc_addname('@', '@'))
        return ret

    def needs_arglist(self) -> bool:
        return False


class PyVariable(PyObject):
    """Description of a variable."""

    option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy()
    option_spec.update({
        'type': directives.unchanged,
        'value': directives.unchanged,
    })

    def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]:
        fullname, prefix = super().handle_signature(sig, signode)

        typ = self.options.get('type')
        if typ:
            annotations = _parse_annotation(typ, self.env)
            signode += addnodes.desc_annotation(
                typ,
                '',
                addnodes.desc_sig_punctuation('', ':'),
                addnodes.desc_sig_space(),
                *annotations,
            )

        value = self.options.get('value')
        if value:
            signode += addnodes.desc_annotation(
                value,
                '',
                addnodes.desc_sig_space(),
                addnodes.desc_sig_punctuation('', '='),
                addnodes.desc_sig_space(),
                nodes.Text(value),
            )

        return fullname, prefix

    def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str:
        name, cls = name_cls
        if modname:
            return _('%s (in module %s)') % (name, modname)
        else:
            return _('%s (built-in variable)') % name


class PyClasslike(PyObject):
    """Description of a class-like object (classes, interfaces, exceptions)."""

    option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy()
    option_spec.update({
        'abstract': directives.flag,
        'final': directives.flag,
    })

    allow_nesting = True

    def get_signature_prefix(self, sig: str) -> Sequence[nodes.Node]:
        prefix: list[addnodes.desc_sig_element] = []
        if 'final' in self.options:
            prefix.extend((
                addnodes.desc_sig_keyword('', 'final'),
                addnodes.desc_sig_space(),
            ))
        if 'abstract' in self.options:
            prefix.extend((
                addnodes.desc_sig_keyword('', 'abstract'),
                addnodes.desc_sig_space(),
            ))
        prefix.extend((
            addnodes.desc_sig_keyword('', self.objtype),
            addnodes.desc_sig_space(),
        ))
        return prefix

    def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str:
        if self.objtype == 'class':
            if not modname:
                return _('%s (built-in class)') % name_cls[0]
            return _('%s (class in %s)') % (name_cls[0], modname)
        elif self.objtype == 'exception':
            return name_cls[0]
        else:
            return ''


class PyMethod(PyObject):
    """Description of a method."""

    option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy()
    option_spec.update({
        'abstract': directives.flag,
        'abstractmethod': directives.flag,
        'async': directives.flag,
        'classmethod': directives.flag,
        'final': directives.flag,
        'staticmethod': directives.flag,
    })

    def needs_arglist(self) -> bool:
        return True

    def get_signature_prefix(self, sig: str) -> Sequence[nodes.Node]:
        prefix: list[addnodes.desc_sig_element] = []
        if 'final' in self.options:
            prefix.extend((
                addnodes.desc_sig_keyword('', 'final'),
                addnodes.desc_sig_space(),
            ))
        if 'abstract' in self.options or 'abstractmethod' in self.options:
            prefix.extend((
                addnodes.desc_sig_keyword('', 'abstractmethod'),
                addnodes.desc_sig_space(),
            ))
        if 'async' in self.options:
            prefix.extend((
                addnodes.desc_sig_keyword('', 'async'),
                addnodes.desc_sig_space(),
            ))
        if 'classmethod' in self.options:
            prefix.extend((
                addnodes.desc_sig_keyword('', 'classmethod'),
                addnodes.desc_sig_space(),
            ))
        if 'staticmethod' in self.options:
            prefix.extend((
                addnodes.desc_sig_keyword('', 'static'),
                addnodes.desc_sig_space(),
            ))
        return prefix

    def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str:
        name, cls = name_cls
        try:
            clsname, methname = name.rsplit('.', 1)
            if modname and self.config.add_module_names:
                clsname = f'{modname}.{clsname}'
        except ValueError:
            if modname:
                return _('%s() (in module %s)') % (name, modname)
            else:
                return '%s()' % name

        if 'classmethod' in self.options:
            return _('%s() (%s class method)') % (methname, clsname)
        elif 'staticmethod' in self.options:
            return _('%s() (%s static method)') % (methname, clsname)
        else:
            return _('%s() (%s method)') % (methname, clsname)


class PyClassMethod(PyMethod):
    """Description of a classmethod."""

    option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy()

    def run(self) -> list[Node]:
        self.name = 'py:method'
        self.options['classmethod'] = True

        return super().run()


class PyStaticMethod(PyMethod):
    """Description of a staticmethod."""

    option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy()

    def run(self) -> list[Node]:
        self.name = 'py:method'
        self.options['staticmethod'] = True

        return super().run()


class PyDecoratorMethod(PyMethod):
    """Description of a decoratormethod."""

    def run(self) -> list[Node]:
        self.name = 'py:method'
        return super().run()

    def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]:
        ret = super().handle_signature(sig, signode)
        signode.insert(0, addnodes.desc_addname('@', '@'))
        return ret

    def needs_arglist(self) -> bool:
        return False


class PyAttribute(PyObject):
    """Description of an attribute."""

    option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy()
    option_spec.update({
        'type': directives.unchanged,
        'value': directives.unchanged,
    })

    def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]:
        fullname, prefix = super().handle_signature(sig, signode)

        typ = self.options.get('type')
        if typ:
            annotations = _parse_annotation(typ, self.env)
            signode += addnodes.desc_annotation(
                typ,
                '',
                addnodes.desc_sig_punctuation('', ':'),
                addnodes.desc_sig_space(),
                *annotations,
            )

        value = self.options.get('value')
        if value:
            signode += addnodes.desc_annotation(
                value,
                '',
                addnodes.desc_sig_space(),
                addnodes.desc_sig_punctuation('', '='),
                addnodes.desc_sig_space(),
                nodes.Text(value),
            )

        return fullname, prefix

    def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str:
        name, cls = name_cls
        try:
            clsname, attrname = name.rsplit('.', 1)
            if modname and self.config.add_module_names:
                clsname = f'{modname}.{clsname}'
        except ValueError:
            if modname:
                return _('%s (in module %s)') % (name, modname)
            else:
                return name

        return _('%s (%s attribute)') % (attrname, clsname)


class PyProperty(PyObject):
    """Description of an attribute."""

    option_spec = PyObject.option_spec.copy()
    option_spec.update({
        'abstract': directives.flag,
        'abstractmethod': directives.flag,
        'classmethod': directives.flag,
        'type': directives.unchanged,
    })

    def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]:
        fullname, prefix = super().handle_signature(sig, signode)

        typ = self.options.get('type')
        if typ:
            annotations = _parse_annotation(typ, self.env)
            signode += addnodes.desc_annotation(
                typ,
                '',
                addnodes.desc_sig_punctuation('', ':'),
                addnodes.desc_sig_space(),
                *annotations,
            )

        return fullname, prefix

    def get_signature_prefix(self, sig: str) -> Sequence[nodes.Node]:
        prefix: list[addnodes.desc_sig_element] = []
        if 'abstract' in self.options or 'abstractmethod' in self.options:
            prefix.extend((
                addnodes.desc_sig_keyword('', 'abstract'),
                addnodes.desc_sig_space(),
            ))
        if 'classmethod' in self.options:
            prefix.extend((
                addnodes.desc_sig_keyword('', 'class'),
                addnodes.desc_sig_space(),
            ))
        prefix.extend((
            addnodes.desc_sig_keyword('', 'property'),
            addnodes.desc_sig_space(),
        ))
        return prefix

    def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str:
        name, cls = name_cls
        try:
            clsname, attrname = name.rsplit('.', 1)
            if modname and self.config.add_module_names:
                clsname = f'{modname}.{clsname}'
        except ValueError:
            if modname:
                return _('%s (in module %s)') % (name, modname)
            else:
                return name

        return _('%s (%s property)') % (attrname, clsname)


class PyTypeAlias(PyObject):
    """Description of a type alias."""

    option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy()
    option_spec.update({
        'canonical': directives.unchanged,
    })

    def get_signature_prefix(self, sig: str) -> Sequence[nodes.Node]:
        return [addnodes.desc_sig_keyword('', 'type'), addnodes.desc_sig_space()]

    def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]:
        fullname, prefix = super().handle_signature(sig, signode)
        if canonical := self.options.get('canonical'):
            canonical_annotations = _parse_annotation(canonical, self.env)
            signode += addnodes.desc_annotation(
                canonical,
                '',
                addnodes.desc_sig_space(),
                addnodes.desc_sig_punctuation('', '='),
                addnodes.desc_sig_space(),
                *canonical_annotations,
            )
        return fullname, prefix

    def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str:
        name, cls = name_cls
        try:
            clsname, attrname = name.rsplit('.', 1)
            if modname and self.config.add_module_names:
                clsname = f'{modname}.{clsname}'
        except ValueError:
            if modname:
                return _('%s (in module %s)') % (name, modname)
            else:
                return name

        return _('%s (type alias in %s)') % (attrname, clsname)


class PyModule(SphinxDirective):
    """Directive to mark description of a new module."""

    has_content = True
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = False
    option_spec: ClassVar[OptionSpec] = {
        'platform': lambda x: x,
        'synopsis': lambda x: x,
        'no-index': directives.flag,
        'no-index-entry': directives.flag,
        'no-contents-entry': directives.flag,
        'no-typesetting': directives.flag,
        'noindex': directives.flag,
        'nocontentsentry': directives.flag,
        'deprecated': directives.flag,
    }

    def run(self) -> list[Node]:
        # Copy old option names to new ones
        # xref RemovedInSphinx90Warning
        # # deprecate noindex in Sphinx 9.0
        if 'no-index' not in self.options and 'noindex' in self.options:
            self.options['no-index'] = self.options['noindex']

        domain = self.env.domains.python_domain

        modname = self.arguments[0].strip()
        no_index = 'no-index' in self.options
        self.env.ref_context['py:module'] = modname

        content_nodes = self.parse_content_to_nodes(allow_section_headings=True)

        ret: list[Node] = []
        if not no_index:
            # note module to the domain
            node_id = make_id(self.env, self.state.document, 'module', modname)
            target = nodes.target('', '', ids=[node_id], ismod=True)
            self.set_source_info(target)
            self.state.document.note_explicit_target(target)

            domain.note_module(
                name=modname,
                node_id=node_id,
                synopsis=self.options.get('synopsis', ''),
                platform=self.options.get('platform', ''),
                deprecated='deprecated' in self.options,
            )
            domain.note_object(modname, 'module', node_id, location=target)

            # the platform and synopsis aren't printed; in fact, they are only
            # used in the modindex currently

            if 'no-index-entry' not in self.options:
                index_text = f'module; {modname}'
                inode = addnodes.index(
                    entries=[('pair', index_text, node_id, '', None)]
                )
                # The node order is: index node first, then target node.
                ret.append(inode)
            ret.append(target)
        ret.extend(content_nodes)
        return ret


class PyCurrentModule(SphinxDirective):
    """This directive is just to tell Sphinx that we're documenting
    stuff in module foo, but links to module foo won't lead here.
    """

    has_content = False
    required_arguments = 1
    optional_arguments = 0
    final_argument_whitespace = False
    option_spec: ClassVar[OptionSpec] = {}

    def run(self) -> list[Node]:
        modname = self.arguments[0].strip()
        if modname == 'None':
            self.env.ref_context.pop('py:module', None)
        else:
            self.env.ref_context['py:module'] = modname
        return []


class PyXRefRole(XRefRole):
    def process_link(
        self,
        env: BuildEnvironment,
        refnode: Element,
        has_explicit_title: bool,
        title: str,
        target: str,
    ) -> tuple[str, str]:
        refnode['py:module'] = env.ref_context.get('py:module')
        refnode['py:class'] = env.ref_context.get('py:class')
        if not has_explicit_title:
            title = title.lstrip('.')  # only has a meaning for the target
            target = target.lstrip('~')  # only has a meaning for the title
            # if the first character is a tilde, don't display the module/class
            # parts of the contents
            if title[0:1] == '~':
                title = title[1:]
                dot = title.rfind('.')
                if dot != -1:
                    title = title[dot + 1 :]
        # if the first character is a dot, search more specific namespaces first
        # else search builtins first
        if target[0:1] == '.':
            target = target[1:]
            refnode['refspecific'] = True
        return title, target


class _PyDecoXRefRole(PyXRefRole):
    def __init__(
        self,
        fix_parens: bool = False,
        lowercase: bool = False,
        nodeclass: type[Element] | None = None,
        innernodeclass: type[TextElement] | None = None,
        warn_dangling: bool = False,
    ) -> None:
        super().__init__(
            fix_parens=True,
            lowercase=lowercase,
            nodeclass=nodeclass,
            innernodeclass=innernodeclass,
            warn_dangling=warn_dangling,
        )

    def update_title_and_target(self, title: str, target: str) -> tuple[str, str]:
        return f'@{title}', target


def filter_meta_fields(
    app: Sphinx, domain: str, objtype: str, content: Element
) -> None:
    """Filter ``:meta:`` field from its docstring."""
    if domain != 'py':
        return

    for node in content:
        if isinstance(node, nodes.field_list):
            fields = cast('list[nodes.field]', node)
            # removing list items while iterating the list needs reversed()
            for field in reversed(fields):
                field_name = cast('nodes.field_body', field[0]).astext().strip()
                if field_name == 'meta' or field_name.startswith('meta '):
                    node.remove(field)


class PythonModuleIndex(Index):
    """Index subclass to provide the Python module index."""

    name = 'modindex'
    localname = _('Python Module Index')
    shortname = _('modules')
    domain: PythonDomain

    def generate(
        self, docnames: Iterable[str] | None = None
    ) -> tuple[list[tuple[str, list[IndexEntry]]], bool]:
        doc_names = frozenset(docnames) if docnames is not None else None

        content: dict[str, list[IndexEntry]] = {}
        # list of prefixes to ignore
        ignores: list[str] = sorted(
            self.domain.env.config['modindex_common_prefix'], key=len, reverse=True
        )

        # list of all modules, sorted by module name
        modules = sorted(self.domain.modules.items(), key=lambda t: t[0].lower())

        # sort out collapsible modules
        prev_modname = ''

        num_top_levels = 0
        for modname, module in modules:
            if doc_names and module.docname not in doc_names:
                continue

            for ignore in ignores:
                if modname.startswith(ignore):
                    modname = modname.removeprefix(ignore)
                    stripped = ignore
                    break
            else:
                stripped = ''

            # we stripped the whole module name?
            if not modname:
                modname, stripped = stripped, ''

            entries = content.setdefault(modname[0].lower(), [])

            package = modname.split('.', maxsplit=1)[0]
            if package != modname:
                # it's a submodule
                if prev_modname == package:
                    # first submodule - make parent a group head
                    if entries:
                        last = entries[-1]
                        entries[-1] = IndexEntry(
                            name=last.name,
                            subtype=1,
                            docname=last.docname,
                            anchor=last.anchor,
                            extra=last.extra,
                            qualifier=last.qualifier,
                            descr=last.descr,
                        )
                elif not prev_modname.startswith(package):
                    # submodule without parent in list, add dummy entry
                    dummy_entry = IndexEntry(
                        name=stripped + package,
                        subtype=1,
                        docname='',
                        anchor='',
                        extra='',
                        qualifier='',
                        descr='',
                    )
                    entries.append(dummy_entry)
                subtype = 2
            else:
                num_top_levels += 1
                subtype = 0

            entry = IndexEntry(
                name=stripped + modname,
                subtype=subtype,
                docname=module.docname,
                anchor=module.node_id,
                extra=module.platform,
                qualifier=_('Deprecated') if module.deprecated else '',
                descr=module.synopsis,
            )
            entries.append(entry)
            prev_modname = modname

        # apply heuristics when to collapse modindex at page load:
        # only collapse if number of toplevel modules is larger than
        # number of submodules
        collapse = len(modules) - num_top_levels < num_top_levels

        # sort by first letter
        sorted_content = sorted(content.items())

        return sorted_content, collapse


class PythonDomain(Domain):
    """Python language domain."""

    name = 'py'
    label = 'Python'
    object_types: dict[str, ObjType] = {
        'function': ObjType(_('function'), 'func', 'obj'),
        'data': ObjType(_('data'), 'data', 'obj'),
        'class': ObjType(_('class'), 'class', 'exc', 'obj'),
        'exception': ObjType(_('exception'), 'exc', 'class', 'obj'),
        'method': ObjType(_('method'), 'meth', 'obj'),
        'classmethod': ObjType(_('class method'), 'meth', 'obj'),
        'staticmethod': ObjType(_('static method'), 'meth', 'obj'),
        'attribute': ObjType(_('attribute'), 'attr', 'obj'),
        'property': ObjType(_('property'), 'attr', '_prop', 'obj'),
        'type': ObjType(_('type alias'), 'type', 'obj'),
        'module': ObjType(_('module'), 'mod', 'obj'),
    }

    directives = {
        'function': PyFunction,
        'data': PyVariable,
        'class': PyClasslike,
        'exception': PyClasslike,
        'method': PyMethod,
        'classmethod': PyClassMethod,
        'staticmethod': PyStaticMethod,
        'attribute': PyAttribute,
        'property': PyProperty,
        'type': PyTypeAlias,
        'module': PyModule,
        'currentmodule': PyCurrentModule,
        'decorator': PyDecoratorFunction,
        'decoratormethod': PyDecoratorMethod,
    }
    roles = {
        'data': PyXRefRole(),
        'exc': PyXRefRole(),
        'func': PyXRefRole(fix_parens=True),
        'deco': _PyDecoXRefRole(),
        'class': PyXRefRole(),
        'const': PyXRefRole(),
        'attr': PyXRefRole(),
        'type': PyXRefRole(),
        'meth': PyXRefRole(fix_parens=True),
        'mod': PyXRefRole(),
        'obj': PyXRefRole(),
    }
    initial_data: dict[str, dict[str, tuple[Any]]] = {
        'objects': {},  # fullname -> docname, objtype
        'modules': {},  # modname -> docname, synopsis, platform, deprecated
    }
    indices = [
        PythonModuleIndex,
    ]

    @property
    def objects(self) -> dict[str, ObjectEntry]:
        return self.data.setdefault('objects', {})  # fullname -> ObjectEntry

    def note_object(
        self,
        name: str,
        objtype: str,
        node_id: str,
        aliased: bool = False,
        location: Any = None,
    ) -> None:
        """Note a python object for cross reference.

        .. versionadded:: 2.1
        """
        if name in self.objects:
            other = self.objects[name]
            if other.aliased and aliased is False:
                # The original definition found. Override it!
                pass
            elif other.aliased is False and aliased:
                # The original definition is already registered.
                return
            else:
                # duplicated
                logger.warning(
                    __(
                        'duplicate object description of %s, '
                        'other instance in %s, use :no-index: for one of them'
                    ),
                    name,
                    other.docname,
                    location=location,
                )
        self.objects[name] = ObjectEntry(self.env.docname, node_id, objtype, aliased)

    @property
    def modules(self) -> dict[str, ModuleEntry]:
        return self.data.setdefault('modules', {})  # modname -> ModuleEntry

    def note_module(
        self, name: str, node_id: str, synopsis: str, platform: str, deprecated: bool
    ) -> None:
        """Note a python module for cross reference.

        .. versionadded:: 2.1
        """
        self.modules[name] = ModuleEntry(
            docname=self.env.docname,
            node_id=node_id,
            synopsis=synopsis,
            platform=platform,
            deprecated=deprecated,
        )

    def clear_doc(self, docname: str) -> None:
        to_remove = [
            fullname for fullname, obj in self.objects.items() if obj.docname == docname
        ]
        for fullname in to_remove:
            del self.objects[fullname]
        to_remove = [
            modname for modname, mod in self.modules.items() if mod.docname == docname
        ]
        for fullname in to_remove:
            del self.modules[fullname]

    def merge_domaindata(self, docnames: Set[str], otherdata: dict[str, Any]) -> None:
        # XXX check duplicates?
        for fullname, obj in otherdata['objects'].items():
            if obj.docname in docnames:
                self.objects[fullname] = obj
        for modname, mod in otherdata['modules'].items():
            if mod.docname in docnames:
                self.modules[modname] = mod

    def find_obj(
        self,
        env: BuildEnvironment,
        modname: str,
        classname: str,
        name: str,
        type: str | None,
        searchmode: int = 0,
    ) -> list[tuple[str, ObjectEntry]]:
        """Find a Python object for "name", perhaps using the given module
        and/or classname.  Returns a list of (name, object entry) tuples.
        """
        # skip parens
        name = name.removesuffix('()')

        if not name:
            return []

        matches: list[tuple[str, ObjectEntry]] = []

        newname = None
        if searchmode == 1:
            if type is None:
                objtypes: list[str] | None = list(self.object_types)
            else:
                objtypes = self.objtypes_for_role(type)
            if objtypes is not None:
                if modname and classname:
                    fullname = modname + '.' + classname + '.' + name
                    if (
                        fullname in self.objects
                        and self.objects[fullname].objtype in objtypes
                    ):
                        newname = fullname
                if not newname:
                    if (
                        modname
                        and f'{modname}.{name}' in self.objects
                        and self.objects[f'{modname}.{name}'].objtype in objtypes
                    ):
                        newname = f'{modname}.{name}'
                    elif (
                        name in self.objects and self.objects[name].objtype in objtypes
                    ):
                        newname = name
                    else:
                        # "fuzzy" searching mode
                        searchname = f'.{name}'
                        matches = [
                            (oname, self.objects[oname])
                            for oname in self.objects
                            if oname.endswith(searchname)
                            and self.objects[oname].objtype in objtypes
                        ]
        else:
            # NOTE: searching for exact match, object type is not considered
            if name in self.objects:
                newname = name
            elif type == 'mod':
                # only exact matches allowed for modules
                return []
            elif classname and classname + '.' + name in self.objects:
                newname = classname + '.' + name
            elif modname and modname + '.' + name in self.objects:
                newname = modname + '.' + name
            elif (
                modname
                and classname
                and modname + '.' + classname + '.' + name in self.objects
            ):
                newname = modname + '.' + classname + '.' + name
        if newname is not None:
            matches.append((newname, self.objects[newname]))
        return matches

    def resolve_xref(
        self,
        env: BuildEnvironment,
        fromdocname: str,
        builder: Builder,
        type: str,
        target: str,
        node: pending_xref,
        contnode: Element,
    ) -> nodes.reference | None:
        modname = node.get('py:module')
        clsname = node.get('py:class')
        searchmode = 1 if node.hasattr('refspecific') else 0
        matches = self.find_obj(env, modname, clsname, target, type, searchmode)

        if not matches and type == 'attr':
            # fallback to meth (for property; Sphinx 2.4.x)
            # this ensures that `:attr:` role continues to refer to the old property entry
            # that defined by ``method`` directive in old reST files.
            matches = self.find_obj(env, modname, clsname, target, 'meth', searchmode)
        if not matches and type == 'meth':
            # fallback to attr (for property)
            # this ensures that `:meth:` in the old reST files can refer to the property
            # entry that defined by ``property`` directive.
            #
            # Note: _prop is a secret role only for internal look-up.
            matches = self.find_obj(env, modname, clsname, target, '_prop', searchmode)

        if not matches:
            return None
        elif len(matches) > 1:
            canonicals = [m for m in matches if not m[1].aliased]
            if len(canonicals) == 1:
                matches = canonicals
            else:
                logger.warning(
                    __('more than one target found for cross-reference %r: %s'),
                    target,
                    ', '.join(match[0] for match in matches),
                    type='ref',
                    subtype='python',
                    location=node,
                )
        name, obj = matches[0]

        if obj[2] == 'module':
            return self._make_module_refnode(builder, fromdocname, name, contnode)
        else:
            # determine the content of the reference by conditions
            content = find_pending_xref_condition(node, 'resolved')
            if content:
                children = content.children
            else:
                # if not found, use contnode
                children = [contnode]

            return make_refnode(builder, fromdocname, obj[0], obj[1], children, name)

    def resolve_any_xref(
        self,
        env: BuildEnvironment,
        fromdocname: str,
        builder: Builder,
        target: str,
        node: pending_xref,
        contnode: Element,
    ) -> list[tuple[str, nodes.reference]]:
        modname = node.get('py:module')
        clsname = node.get('py:class')
        results: list[tuple[str, nodes.reference]] = []

        # always search in "refspecific" mode with the :any: role
        matches = self.find_obj(env, modname, clsname, target, None, 1)
        multiple_matches = len(matches) > 1

        for name, obj in matches:
            if multiple_matches and obj.aliased:
                # Skip duplicated matches
                continue

            if obj[2] == 'module':
                results.append((
                    'py:mod',
                    self._make_module_refnode(builder, fromdocname, name, contnode),
                ))
            else:
                # determine the content of the reference by conditions
                content = find_pending_xref_condition(node, 'resolved')
                if content:
                    children = content.children
                else:
                    # if not found, use contnode
                    children = [contnode]

                role = 'py:' + self.role_for_objtype(obj[2])  # type: ignore[operator]
                results.append((
                    role,
                    make_refnode(builder, fromdocname, obj[0], obj[1], children, name),
                ))
        return results

    def _make_module_refnode(
        self, builder: Builder, fromdocname: str, name: str, contnode: Node
    ) -> nodes.reference:
        # get additional info for modules
        module: ModuleEntry = self.modules[name]
        title_parts = [name]
        if module.synopsis:
            title_parts.append(f': {module.synopsis}')
        if module.deprecated:
            title_parts.append(_(' (deprecated)'))
        if module.platform:
            title_parts.append(f' ({module.platform})')
        title = ''.join(title_parts)
        return make_refnode(
            builder, fromdocname, module.docname, module.node_id, contnode, title
        )

    def get_objects(self) -> Iterator[tuple[str, str, str, str, str, int]]:
        for modname, mod in self.modules.items():
            yield modname, modname, 'module', mod.docname, mod.node_id, 0
        for refname, obj in self.objects.items():
            if obj.objtype != 'module':  # modules are already handled
                if obj.aliased:
                    # aliased names are not full-text searchable.
                    yield refname, refname, obj.objtype, obj.docname, obj.node_id, -1
                else:
                    yield refname, refname, obj.objtype, obj.docname, obj.node_id, 1

    def get_full_qualified_name(self, node: Element) -> str | None:
        modname = node.get('py:module')
        clsname = node.get('py:class')
        target = node.get('reftarget')
        if target is None:
            return None
        else:
            return '.'.join(filter(None, [modname, clsname, target]))


def builtin_resolver(
    app: Sphinx, env: BuildEnvironment, node: pending_xref, contnode: Element
) -> Element | None:
    """Do not emit nitpicky warnings for built-in types."""

    def istyping(s: str) -> bool:
        if s.startswith('typing.'):
            s = s.split('.', 1)[1]

        return s in typing.__all__

    if node.get('refdomain') != 'py':
        return None
    elif node.get('reftype') in {'class', 'obj'} and node.get('reftarget') == 'None':
        return contnode
    elif node.get('reftype') in {'class', 'obj', 'exc'}:
        reftarget = node.get('reftarget')
        if inspect.isclass(getattr(builtins, reftarget, None)):
            # built-in class
            return contnode
        if istyping(reftarget):
            # typing class
            return contnode

    return None


def setup(app: Sphinx) -> ExtensionMetadata:
    app.setup_extension('sphinx.directives')

    app.add_domain(PythonDomain)
    app.add_config_value(
        'python_use_unqualified_type_names', False, 'env', types=frozenset({bool})
    )
    app.add_config_value(
        'python_maximum_signature_line_length',
        None,
        'env',
        types=frozenset({int, NoneType}),
    )
    app.add_config_value(
        'python_trailing_comma_in_multi_line_signatures',
        True,
        'env',
        types=frozenset({bool}),
    )
    app.add_config_value(
        'python_display_short_literal_types', False, 'env', types=frozenset({bool})
    )
    app.connect('object-description-transform', filter_meta_fields)
    app.connect('missing-reference', builtin_resolver, priority=900)

    return {
        'version': 'builtin',
        'env_version': 4,
        'parallel_read_safe': True,
        'parallel_write_safe': True,
    }
