"""Preserve function defaults.

Preserve the default argument values of function signatures in source code
and keep them not evaluated for readability.
"""

from __future__ import annotations

import ast
import inspect
import types
import warnings
from typing import TYPE_CHECKING

import sphinx
from sphinx.deprecation import RemovedInSphinx90Warning
from sphinx.locale import __
from sphinx.pycode.ast import unparse as ast_unparse
from sphinx.util import logging

if TYPE_CHECKING:
    from typing import Any

    from sphinx.application import Sphinx
    from sphinx.util.typing import ExtensionMetadata

logger = logging.getLogger(__name__)
_LAMBDA_NAME = (lambda: None).__name__


class DefaultValue:
    def __init__(self, name: str) -> None:
        self.name = name

    def __repr__(self) -> str:
        return self.name


def get_function_def(obj: Any) -> ast.FunctionDef | None:
    """Get FunctionDef object from living object.

    This tries to parse original code for living object and returns
    AST node for given *obj*.
    """
    warnings.warn(
        'sphinx.ext.autodoc.preserve_defaults.get_function_def is'
        ' deprecated and scheduled for removal in Sphinx 9.'
        ' Use sphinx.ext.autodoc.preserve_defaults._get_arguments() to'
        ' extract AST arguments objects from a lambda or regular'
        ' function.',
        RemovedInSphinx90Warning,
        stacklevel=2,
    )

    try:
        source = inspect.getsource(obj)
        if source.startswith((' ', '\t')):
            # subject is placed inside class or block.  To read its docstring,
            # this adds if-block before the declaration.
            module = ast.parse('if True:\n' + source)
            return module.body[0].body[0]  # type: ignore[attr-defined]
        else:
            module = ast.parse(source)
            return module.body[0]  # type: ignore[return-value]
    except (OSError, TypeError):  # failed to load source code
        return None


def _get_arguments(obj: Any, /) -> ast.arguments | None:
    """Parse 'ast.arguments' from an object.

    This tries to parse the original code for an object and returns
    an 'ast.arguments' node.
    """
    try:
        source = inspect.getsource(obj)
        if source.startswith((' ', '\t')):
            # 'obj' is in some indented block.
            module = ast.parse('if True:\n' + source)
            subject = module.body[0].body[0]  # type: ignore[attr-defined]
        else:
            module = ast.parse(source)
            subject = module.body[0]
    except (OSError, TypeError):
        # bail; failed to load source for 'obj'.
        return None
    except SyntaxError:
        if _is_lambda(obj):
            # Most likely a multi-line arising from detecting a lambda, e.g.:
            #
            # class Egg:
            #     x = property(
            #         lambda self: 1, doc="...")
            return None

        # Other syntax errors that are not due to the fact that we are
        # documenting a lambda function are propagated
        # (in particular if a lambda is renamed by the user).
        raise

    return _get_arguments_inner(subject)


def _is_lambda(x: Any, /) -> bool:
    return isinstance(x, types.LambdaType) and x.__name__ == _LAMBDA_NAME


def _get_arguments_inner(x: Any, /) -> ast.arguments | None:
    if isinstance(x, ast.AsyncFunctionDef | ast.FunctionDef | ast.Lambda):
        return x.args
    if isinstance(x, ast.Assign | ast.AnnAssign):
        return _get_arguments_inner(x.value)
    return None


def get_default_value(lines: list[str], position: ast.expr) -> str | None:
    try:
        if position.lineno == position.end_lineno:
            line = lines[position.lineno - 1]
            return line[position.col_offset : position.end_col_offset]
        else:
            # multiline value is not supported now
            return None
    except (AttributeError, IndexError):
        return None


def update_defvalue(app: Sphinx, obj: Any, bound_method: bool) -> None:
    """Update defvalue info of *obj* using type_comments."""
    if not app.config.autodoc_preserve_defaults:
        return

    try:
        lines = inspect.getsource(obj).splitlines()
        if lines[0].startswith((' ', '\t')):
            # insert a dummy line to follow what _get_arguments() does.
            lines.insert(0, '')
    except (OSError, TypeError):
        lines = []

    try:
        args = _get_arguments(obj)
    except SyntaxError:
        return
    if args is None:
        # If the object is a built-in, we won't be always able to recover
        # the function definition and its arguments. This happens if *obj*
        # is the `__init__` method generated automatically for dataclasses.
        return

    if not args.defaults and not args.kw_defaults:
        return

    try:
        if bound_method and inspect.ismethod(obj) and hasattr(obj, '__func__'):
            sig = inspect.signature(obj.__func__)
        else:
            sig = inspect.signature(obj)
        defaults = list(args.defaults)
        kw_defaults = list(args.kw_defaults)
        parameters = list(sig.parameters.values())
        for i, param in enumerate(parameters):
            if param.default is param.empty:
                if param.kind == param.KEYWORD_ONLY:
                    # Consume kw_defaults for kwonly args
                    kw_defaults.pop(0)
            else:
                if param.kind in {param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD}:
                    default = defaults.pop(0)
                    value = get_default_value(lines, default)
                    if value is None:
                        value = ast_unparse(default)
                    parameters[i] = param.replace(default=DefaultValue(value))
                else:
                    default = kw_defaults.pop(0)  # type: ignore[assignment]
                    value = get_default_value(lines, default)
                    if value is None:
                        value = ast_unparse(default)
                    parameters[i] = param.replace(default=DefaultValue(value))

        sig = sig.replace(parameters=parameters)
        try:
            obj.__signature__ = sig
        except AttributeError:
            # __signature__ can't be set directly on bound methods.
            obj.__dict__['__signature__'] = sig
    except (AttributeError, TypeError):
        # Failed to update signature (e.g. built-in or extension types).
        # For user-defined functions, "obj" may not have __dict__,
        # e.g. when decorated with a class that defines __slots__.
        # In this case, we can't set __signature__.
        return
    except NotImplementedError as exc:  # failed to ast_unparse()
        logger.warning(
            __('Failed to parse a default argument value for %r: %s'), obj, exc
        )


def setup(app: Sphinx) -> ExtensionMetadata:
    app.add_config_value(
        'autodoc_preserve_defaults', False, 'env', types=frozenset({bool})
    )
    app.connect('autodoc-before-process-signature', update_defvalue)

    return {
        'version': sphinx.__display_version__,
        'parallel_read_safe': True,
    }
