"""Signature utilities for autodoc"""

from __future__ import annotations

import sys
from inspect import Parameter, Signature
from typing import TYPE_CHECKING, NewType, TypeVar

from sphinx.errors import PycodeError
from sphinx.ext.autodoc._dynamic._preserve_defaults import update_default_value
from sphinx.ext.autodoc._dynamic._type_annotations import _record_typehints
from sphinx.ext.autodoc._dynamic._type_comments import (
    _update_annotations_using_type_comments,
)
from sphinx.ext.autodoc._names import py_ext_sig_re
from sphinx.ext.autodoc._property_types import _AssignStatementProperties
from sphinx.ext.autodoc._shared import LOGGER
from sphinx.locale import __
from sphinx.pycode import ModuleAnalyzer
from sphinx.util import inspect
from sphinx.util.docstrings import prepare_docstring
from sphinx.util.inspect import (
    _stringify_signature_to_parts,
    evaluate_signature,
    safe_getattr,
)

if TYPE_CHECKING:
    from collections.abc import Callable, Mapping
    from typing import Any, TypeAlias

    from sphinx.events import EventManager
    from sphinx.ext.autodoc._directive_options import _AutoDocumenterOptions
    from sphinx.ext.autodoc._property_types import _ItemProperties
    from sphinx.ext.autodoc._shared import _AttrGetter, _AutodocConfig

    _FormattedSignature: TypeAlias = tuple[str, str]


def _format_signatures(
    *,
    autodoc_annotations: dict[str, dict[str, str]],
    config: _AutodocConfig,
    docstrings: list[list[str]] | None,
    events: EventManager,
    get_attr: _AttrGetter,
    parent: Any,
    options: _AutoDocumenterOptions,
    props: _ItemProperties,
    args: str | None = None,
    retann: str | None = '',
    **kwargs: Any,
) -> list[_FormattedSignature]:
    """Format the signature (arguments and return annotation) of the object.

    Let the user process it via the ``autodoc-process-signature`` event.
    """
    if props.obj_type in {'class', 'exception'}:
        from sphinx.ext.autodoc._property_types import _ClassDefProperties

        assert isinstance(props, _ClassDefProperties)
        if props.doc_as_attr:
            return []
        if config.autodoc_class_signature == 'separated':
            # do not show signatures
            return []

    if config.autodoc_typehints_format == 'short':
        kwargs.setdefault('unqualified_typehints', True)
    if config.python_display_short_literal_types:
        kwargs.setdefault('short_literals', True)

    if args is None:
        signatures: list[_FormattedSignature] = []
    else:
        signatures = [(args, retann or '')]

    if (
        not signatures
        and config.autodoc_docstring_signature
        and props.obj_type not in {'module', 'data', 'type'}
        and docstrings is not None
    ):
        # only act if a signature is not explicitly given already,
        # and if the feature is enabled
        signatures[:] = _extract_signatures_from_docstrings(
            docstrings, props=props, tab_width=options._tab_width
        )

    if not signatures:
        # try to introspect the signature
        try:
            signatures[:] = _extract_signature_from_object(
                config=config,
                events=events,
                get_attr=get_attr,
                parent=parent,
                props=props,
                **kwargs,
            )
        except Exception as exc:
            msg = __('error while formatting arguments for %s: %s')
            LOGGER.warning(msg, props.full_name, exc, type='autodoc')

    if props.obj_type in {'attribute', 'property'}:
        # Only keep the return annotation
        signatures = [('', retann) for _args, retann in signatures]

    _record_typehints(
        autodoc_annotations=autodoc_annotations,
        name=props.full_name,
        obj=props._obj,
        short_literals=kwargs.get('short_literals', False),
        type_aliases=config.autodoc_type_aliases,
        unqualified_typehints=kwargs.get('unqualified_typehints', False),
    )
    if result := events.emit_firstresult(
        'autodoc-process-signature',
        props.obj_type,
        props.full_name,
        props._obj,
        options,
        signatures[0][0] if signatures else None,  # args
        signatures[0][1] if signatures else '',  # retann
    ):
        if len(result) == 2 and isinstance(result[0], str):
            args, retann = result
            signatures[0] = (args, retann if isinstance(retann, str) else '')

    if props.obj_type in {'module', 'data', 'type'}:
        signatures[1:] = ()  # discard all signatures save the first

    analyzer_overloads: dict[str, list[Signature]] = {}
    try:
        analyzer = ModuleAnalyzer.for_module(props.canonical_module_name)
        # parse right now, to get PycodeErrors on parsing (results will
        # be cached anyway)
        analyzer.analyze()
    except PycodeError as exc:
        LOGGER.debug('[autodoc] module analyzer failed: %s', exc)
        # no source file -- e.g. for builtin and C modules
    else:
        analyzer_overloads = analyzer.overloads

    if props.obj_type in {'function', 'decorator'}:
        overloaded = (
            props.dotted_parts in analyzer_overloads
            and config.autodoc_typehints != 'none'
        )
        is_singledispatch = inspect.is_singledispatch_function(props._obj)

        if overloaded:
            # Use signatures for overloaded functions and methods instead of
            # their implementations.
            signatures.clear()
        elif not is_singledispatch:
            return signatures

        if is_singledispatch:
            from sphinx.ext.autodoc._property_types import _FunctionDefProperties

            # append signature of singledispatch'ed functions
            for typ, func in props._obj.registry.items():
                if typ is object:
                    continue  # default implementation. skipped.
                dispatch_func = _annotate_to_first_argument(
                    func, typ, config=config, props=props
                )
                if not dispatch_func:
                    continue
                dispatch_props = _FunctionDefProperties(
                    obj_type='function',
                    module_name='',
                    parts=('',),
                    docstring_lines=(),
                    signatures=(),
                    _obj=dispatch_func,
                    _obj___module__=None,
                    _obj___qualname__=None,
                    _obj___name__=None,
                    properties=frozenset(),
                )
                signatures += _format_signatures(
                    autodoc_annotations=autodoc_annotations,
                    config=config,
                    docstrings=None,
                    events=events,
                    get_attr=get_attr,
                    parent=None,
                    options=options,
                    props=dispatch_props,
                )
        if overloaded:
            actual = inspect.signature(
                props._obj, type_aliases=config.autodoc_type_aliases
            )
            obj_globals = safe_getattr(props._obj, '__globals__', {})
            overloads = analyzer_overloads[props.dotted_parts]
            for overload in overloads:
                overload = _merge_default_value(actual, overload)
                overload = evaluate_signature(
                    overload, obj_globals, config.autodoc_type_aliases
                )
                signatures.append(_stringify_signature_to_parts(overload, **kwargs))

        return signatures

    if props.obj_type in {'class', 'exception'}:
        from sphinx.ext.autodoc._property_types import _ClassDefProperties

        assert isinstance(props, _ClassDefProperties)
        method_name = props._signature_method_name
        if method_name == '__call__':
            signature_cls = type(props._obj)
        else:
            signature_cls = props._obj
        overloads = []
        overloaded = False
        if method_name:
            for cls in signature_cls.__mro__:
                try:
                    analyzer = ModuleAnalyzer.for_module(cls.__module__)
                    analyzer.analyze()
                except PycodeError:
                    pass
                else:
                    qualname = f'{cls.__qualname__}.{method_name}'
                    if qualname in analyzer.overloads:
                        overloads = analyzer.overloads[qualname]
                        overloaded = True
                        break
                    if qualname in analyzer.tagorder:
                        # the constructor is defined in the class, but not overridden.
                        break
        if overloaded and config.autodoc_typehints != 'none':
            # Use signatures for overloaded methods instead of the implementation method.
            signatures.clear()
            method = safe_getattr(signature_cls, method_name, None)
            method_globals = safe_getattr(method, '__globals__', {})
            for overload in overloads:
                overload = evaluate_signature(
                    overload, method_globals, config.autodoc_type_aliases
                )

                parameters = list(overload.parameters.values())
                overload = overload.replace(
                    parameters=parameters[1:], return_annotation=Parameter.empty
                )
                signatures.append(_stringify_signature_to_parts(overload, **kwargs))
            return signatures

        return signatures

    if props.obj_type == 'method':
        overloaded = (
            props.dotted_parts in analyzer_overloads
            and config.autodoc_typehints != 'none'
        )
        meth = parent.__dict__.get(props.name)
        is_singledispatch = inspect.is_singledispatch_method(meth)

        if overloaded:
            # Use signatures for overloaded functions and methods instead of
            # their implementations.
            signatures.clear()
        elif not is_singledispatch:
            return signatures

        if is_singledispatch:
            from sphinx.ext.autodoc._property_types import _FunctionDefProperties

            # append signature of singledispatch'ed methods
            for typ, func in meth.dispatcher.registry.items():
                if typ is object:
                    continue  # default implementation. skipped.
                if inspect.isclassmethod(func):
                    func = func.__func__
                dispatch_meth = _annotate_to_first_argument(
                    func, typ, config=config, props=props
                )
                if not dispatch_meth:
                    continue
                dispatch_props = _FunctionDefProperties(
                    obj_type='method',
                    module_name='',
                    parts=('',),
                    docstring_lines=(),
                    signatures=(),
                    _obj=dispatch_meth,
                    _obj___module__=None,
                    _obj___qualname__=None,
                    _obj___name__=None,
                    properties=frozenset(),
                )
                signatures += _format_signatures(
                    autodoc_annotations=autodoc_annotations,
                    config=config,
                    docstrings=None,
                    events=events,
                    get_attr=get_attr,
                    parent=parent,
                    options=options,
                    props=dispatch_props,
                )
        if overloaded:
            from sphinx.ext.autodoc._property_types import _FunctionDefProperties

            assert isinstance(props, _FunctionDefProperties)
            actual = inspect.signature(
                props._obj,
                bound_method=not props.is_staticmethod,
                type_aliases=config.autodoc_type_aliases,
            )

            obj_globals = safe_getattr(props._obj, '__globals__', {})
            overloads = analyzer_overloads[props.dotted_parts]
            for overload in overloads:
                overload = _merge_default_value(actual, overload)
                overload = evaluate_signature(
                    overload, obj_globals, config.autodoc_type_aliases
                )

                if not props.is_staticmethod:
                    # hide the first argument (e.g. 'self')
                    parameters = list(overload.parameters.values())
                    overload = overload.replace(parameters=parameters[1:])
                signatures.append(_stringify_signature_to_parts(overload, **kwargs))

        return signatures

    return signatures


def _extract_signatures_from_docstrings(
    docstrings: list[list[str]],
    /,
    props: _ItemProperties,
    tab_width: int,
) -> list[_FormattedSignature]:
    signatures: list[_FormattedSignature] = []

    # candidates of the object name
    valid_names = {props.name}
    if props.obj_type in {'class', 'exception'}:
        valid_names.add('__init__')
        if hasattr(props._obj, '__mro__'):
            valid_names |= {cls.__name__ for cls in props._obj.__mro__}

    stripped_docstrings = [list(l) for l in (docstrings or ())]
    for i, doclines in enumerate(docstrings):
        j = 0
        for j, line in enumerate(doclines):  # NoQA: B007
            if not line:
                # no lines in docstring, no match
                break
            line = line.rstrip('\\').rstrip()

            # match first line of docstring against signature RE
            match = py_ext_sig_re.match(line)
            if not match:
                break
            _exmod, _path, base, _tp_list, args, retann = match.groups()
            if args is not None:
                args = f'({args})'
            else:
                args = ''  # i.e. property or attribute

            # the base name must match ours
            if base not in valid_names:
                break

            if props.obj_type in {'class', 'exception'} and retann == 'None':
                # Strip a return value from signatures of constructor in docstring
                signatures.append((args, ''))
            else:
                signatures.append((args, retann or ''))

        if signatures:
            # re-prepare docstring to ignore more leading indentation
            stripped_docstrings[i] = prepare_docstring(
                '\n'.join(doclines[j:]), tab_width
            )

            # finish the loop after finding at least one signature
            break

    if not signatures:
        return []

    # Update docstrings from stripped_docstrings if needed
    if props.obj_type in {
        'class',
        'exception',
        'function',
        'method',
        'property',
        'decorator',
    } or (
        props.obj_type == 'attribute'
        and isinstance(props, _AssignStatementProperties)
        and props._obj_is_attribute_descriptor
    ):
        docstrings[:] = stripped_docstrings

    return signatures


def _extract_signature_from_object(
    config: _AutodocConfig,
    events: EventManager,
    get_attr: _AttrGetter,
    parent: Any,
    props: _ItemProperties,
    **kwargs: Any,
) -> list[_FormattedSignature]:
    """Format the signature using runtime introspection."""
    sig = _get_signature_object(
        events=events,
        get_attr=get_attr,
        parent=parent,
        preserve_defaults=config.autodoc_preserve_defaults,
        props=props,
        type_aliases=config.autodoc_type_aliases,
        use_type_comments=config.autodoc_use_type_comments,
    )
    if sig is None:
        return []

    if props.obj_type == 'decorator' and len(sig.parameters) == 1:
        # Special case for single-argument decorators
        return [('', '')]

    if config.autodoc_typehints in {'none', 'description'}:
        kwargs.setdefault('show_annotation', False)
    if config.autodoc_typehints_format == 'short':
        kwargs.setdefault('unqualified_typehints', True)
    if config.python_display_short_literal_types:
        kwargs.setdefault('short_literals', True)
    if props.obj_type in {'class', 'exception'}:
        kwargs['show_return_annotation'] = False

    args, retann = _stringify_signature_to_parts(sig, **kwargs)
    if config.strip_signature_backslash:
        # escape backslashes for reST
        args = args.replace('\\', '\\\\')
        retann = retann.replace('\\', '\\\\')

    return [(args, retann)]


# Types which have confusing metaclass signatures it would be best not to show.
# These are listed by name, rather than storing the objects themselves, to avoid
# needing to import the modules.
_METACLASS_CALL_BLACKLIST = frozenset({
    'enum.EnumType.__call__',
})


# Types whose __new__ signature is a pass-through.
_CLASS_NEW_BLACKLIST = frozenset({
    'typing.Generic.__new__',
})


def _get_signature_object(
    events: EventManager,
    get_attr: _AttrGetter,
    parent: Any,
    preserve_defaults: bool,
    props: _ItemProperties,
    type_aliases: Mapping[str, str] | None,
    use_type_comments: bool,
) -> Signature | None:
    """Return a Signature for *obj*, or None on failure."""
    obj, is_bound_method = _get_object_for_signature(
        props=props, get_attr=get_attr, parent=parent, type_aliases=type_aliases
    )
    if obj is None or isinstance(obj, Signature):
        return obj

    if preserve_defaults:
        update_default_value(obj, bound_method=is_bound_method)
    if use_type_comments:
        _update_annotations_using_type_comments(obj, bound_method=is_bound_method)
    events.emit('autodoc-before-process-signature', obj, is_bound_method)

    if props.obj_type in {'class', 'exception', 'function', 'method', 'decorator'}:
        try:
            return inspect.signature(
                obj, bound_method=is_bound_method, type_aliases=type_aliases
            )
        except TypeError as exc:
            if props.obj_type in {'class', 'exception'}:
                msg = __('Failed to get a constructor signature for %s: %s')
            elif props.obj_type in {'function', 'decorator'}:
                msg = __('Failed to get a function signature for %s: %s')
            elif props.obj_type == 'method':
                msg = __('Failed to get a method signature for %s: %s')
            else:
                msg = __('Failed to get a signature for %s: %s')
            LOGGER.warning(msg, props.full_name, exc)
            return None
        except ValueError:
            # Still no signature: happens e.g. for old-style classes
            # with __init__ in C and no `__text_signature__`.
            return None

    return None


def _get_object_for_signature(
    props: _ItemProperties,
    get_attr: _AttrGetter,
    parent: Any,
    type_aliases: Mapping[str, str] | None,
) -> tuple[Any, bool]:
    """Return the object from which we will obtain the signature."""
    obj = props._obj
    if props.obj_type in {'function', 'decorator'}:
        return obj, False

    if props.obj_type in {'class', 'exception'}:
        if isinstance(obj, (NewType, TypeVar)):
            # Suppress signature
            return None, False

        try:
            object_sig = obj.__signature__
        except AttributeError:
            pass
        else:
            if isinstance(object_sig, Signature):
                return object_sig, False
            if sys.version_info[:2] in {(3, 12), (3, 13)} and callable(object_sig):
                # Support for enum.Enum.__signature__ in Python 3.12
                if isinstance(object_sig_str := object_sig(), str):
                    return inspect.signature_from_str(object_sig_str), False

        def get_user_defined_function_or_method(obj: Any, attr: str) -> Any:
            """Get the `attr` function or method from `obj`, if it is user-defined."""
            if inspect.is_builtin_class_method(obj, attr):
                return None
            attr = get_attr(obj, attr, None)
            if not (inspect.ismethod(attr) or inspect.isfunction(attr)):
                return None
            return attr

        # This sequence is copied from inspect._signature_from_callable.
        # ValueError means that no signature could be found, so we keep going.

        # Let's see if it has an overloaded __call__ defined in its metaclass,
        # or if the 'obj' class has a '__new__' or '__init__' method
        for obj_, meth_name, blacklist in (
            (type(obj), '__call__', _METACLASS_CALL_BLACKLIST),
            (obj, '__new__', _CLASS_NEW_BLACKLIST),
            (obj, '__init__', frozenset()),
        ):
            meth = get_user_defined_function_or_method(obj_, meth_name)
            if meth is None:
                continue
            if blacklist:
                if f'{meth.__module__}.{meth.__qualname__}' in blacklist:
                    continue

            try:
                inspect.signature(meth, bound_method=True, type_aliases=type_aliases)
            except TypeError:
                return meth, True  # _get_signature_object() needs to log the failure
            except ValueError:
                continue
            else:
                from sphinx.ext.autodoc._property_types import _ClassDefProperties

                assert isinstance(props, _ClassDefProperties)
                props._signature_method_name = meth_name
                return meth, True

        # None of the attributes are user-defined, so fall back to let inspect
        # handle it.
        # We don't know the exact method that inspect.signature will read
        # the signature from, so just return the object itself to be passed
        # to the ``autodoc-before-process-signature`` hook.
        return obj, False

    if props.obj_type == 'method':
        if obj == object.__init__ and parent != object:  # NoQA: E721
            # Classes not having own __init__() method are shown as no arguments.
            #
            # Note: The signature of object.__init__() is (self, /, *args, **kwargs).
            #       But it makes users confused.
            return Signature(), False

        is_bound_method = not inspect.isstaticmethod(
            obj, cls=parent, name=props.object_name
        )
        return obj, is_bound_method

    return None, False


def _annotate_to_first_argument(
    func: Callable[..., Any],
    typ: type,
    *,
    config: _AutodocConfig,
    props: _ItemProperties,
) -> Callable[..., Any] | None:
    """Annotate type hint to the first argument of function if needed."""
    try:
        sig = inspect.signature(func, type_aliases=config.autodoc_type_aliases)
    except TypeError as exc:
        msg = __('Failed to get a function signature for %s: %s')
        LOGGER.warning(msg, props.full_name, exc)
        return None
    except ValueError:
        return None

    first_arg_idx = 1 * (props.obj_type == 'method')
    if len(sig.parameters) == first_arg_idx:
        return None

    def dummy():  # type: ignore[no-untyped-def]  # NoQA: ANN202
        pass

    params = list(sig.parameters.values())
    if params[first_arg_idx].annotation is Parameter.empty:
        params[first_arg_idx] = params[first_arg_idx].replace(annotation=typ)
        try:
            dummy.__signature__ = sig.replace(parameters=params)  # type: ignore[attr-defined]
            return dummy
        except (AttributeError, TypeError):
            # failed to update signature (ex. built-in or extension types)
            return None

    return func


def _merge_default_value(actual: Signature, overload: Signature) -> Signature:
    """Merge default values of actual implementation to the overload variants."""
    parameters = list(overload.parameters.values())
    for i, param in enumerate(parameters):
        actual_param = actual.parameters.get(param.name)
        if actual_param and param.default == '...':
            parameters[i] = param.replace(default=actual_param.default)

    return overload.replace(parameters=parameters)
