"""
Helpers for retrieving the function signature of the function call that we are
editing.

Either with the Jedi library, or using `inspect.signature` if Jedi fails and we
can use `eval()` to evaluate the function object.
"""
from __future__ import annotations

import inspect
from inspect import Signature as InspectSignature
from inspect import _ParameterKind as ParameterKind
from typing import TYPE_CHECKING, Any, Sequence

from prompt_toolkit.document import Document

from .completer import DictionaryCompleter
from .utils import get_jedi_script_from_document

if TYPE_CHECKING:
    import jedi.api.classes

__all__ = ["Signature", "get_signatures_using_jedi", "get_signatures_using_eval"]


class Parameter:
    def __init__(
        self,
        name: str,
        annotation: str | None,
        default: str | None,
        kind: ParameterKind,
    ) -> None:
        self.name = name
        self.kind = kind

        self.annotation = annotation
        self.default = default

    def __repr__(self) -> str:
        return f"Parameter(name={self.name!r})"

    @property
    def description(self) -> str:
        """
        Name + annotation.
        """
        description = self.name

        if self.annotation is not None:
            description += f": {self.annotation}"

        return description


class Signature:
    """
    Signature definition used wrap around both Jedi signatures and
    python-inspect signatures.

    :param index: Parameter index of the current cursor position.
    :param bracket_start: (line, column) tuple for the open bracket that starts
        the function call.
    """

    def __init__(
        self,
        name: str,
        docstring: str,
        parameters: Sequence[Parameter],
        index: int | None = None,
        returns: str = "",
        bracket_start: tuple[int, int] = (0, 0),
    ) -> None:
        self.name = name
        self.docstring = docstring
        self.parameters = parameters
        self.index = index
        self.returns = returns
        self.bracket_start = bracket_start

    @classmethod
    def from_inspect_signature(
        cls,
        name: str,
        docstring: str,
        signature: InspectSignature,
        index: int,
    ) -> Signature:
        parameters = []

        def get_annotation_name(annotation: object) -> str:
            """
            Get annotation as string from inspect signature.
            """
            try:
                # In case the annotation is a class like "int", "float", ...
                return str(annotation.__name__)  # type: ignore
            except AttributeError:
                pass  # No attribute `__name__`, e.g., in case of `List[int]`.

            annotation = str(annotation)
            if annotation.startswith("typing."):
                annotation = annotation[len("typing:") :]
            return annotation

        for p in signature.parameters.values():
            parameters.append(
                Parameter(
                    name=p.name,
                    annotation=get_annotation_name(p.annotation),
                    default=repr(p.default)
                    if p.default is not inspect.Parameter.empty
                    else None,
                    kind=p.kind,
                )
            )

        return cls(
            name=name,
            docstring=docstring,
            parameters=parameters,
            index=index,
            returns="",
        )

    @classmethod
    def from_jedi_signature(cls, signature: jedi.api.classes.Signature) -> Signature:
        parameters = []

        for p in signature.params:
            if p is None:
                # We just hit the "*".
                continue

            parameters.append(
                Parameter(
                    name=p.to_string(),  # p.name, (`to_string()` already includes the annotation).
                    annotation=None,  # p.infer_annotation()
                    default=None,  # p.infer_default()
                    kind=p.kind,
                )
            )

        docstring = signature.docstring()
        if not isinstance(docstring, str):
            docstring = docstring.decode("utf-8")

        return cls(
            name=signature.name,
            docstring=docstring,
            parameters=parameters,
            index=signature.index,
            returns="",
            bracket_start=signature.bracket_start,
        )

    def __repr__(self) -> str:
        return f"Signature({self.name!r}, parameters={self.parameters!r})"


def get_signatures_using_jedi(
    document: Document, locals: dict[str, Any], globals: dict[str, Any]
) -> list[Signature]:
    script = get_jedi_script_from_document(document, locals, globals)

    # Show signatures in help text.
    if not script:
        return []

    try:
        signatures = script.get_signatures()
    except ValueError:
        # e.g. in case of an invalid \\x escape.
        signatures = []
    except Exception:
        # Sometimes we still get an exception (TypeError), because
        # of probably bugs in jedi. We can silence them.
        # See: https://github.com/davidhalter/jedi/issues/492
        signatures = []
    else:
        # Try to access the params attribute just once. For Jedi
        # signatures containing the keyword-only argument star,
        # this will crash when retrieving it the first time with
        # AttributeError. Every following time it works.
        # See: https://github.com/jonathanslenders/ptpython/issues/47
        #      https://github.com/davidhalter/jedi/issues/598
        try:
            if signatures:
                signatures[0].params
        except AttributeError:
            pass

    return [Signature.from_jedi_signature(sig) for sig in signatures]


def get_signatures_using_eval(
    document: Document, locals: dict[str, Any], globals: dict[str, Any]
) -> list[Signature]:
    """
    Look for the signature of the function before the cursor position without
    use of Jedi. This uses a similar approach as the `DictionaryCompleter` of
    running `eval()` over the detected function name.
    """
    # Look for open parenthesis, before cursor position.
    pos = document.cursor_position - 1

    paren_mapping = {")": "(", "}": "{", "]": "["}
    paren_stack = [
        ")"
    ]  # Start stack with closing ')'. We are going to look for the matching open ')'.
    comma_count = 0  # Number of comma's between start of function call and cursor pos.
    found_start = False  # Found something.

    while pos >= 0:
        char = document.text[pos]
        if char in ")]}":
            paren_stack.append(char)
        elif char in "([{":
            if not paren_stack:
                # Open paren, while no closing paren was found. Mouse cursor is
                # positioned in nested parentheses. Not at the "top-level" of a
                # function call.
                break
            if paren_mapping[paren_stack[-1]] != char:
                # Unmatching parentheses: syntax error?
                break

            paren_stack.pop()

            if len(paren_stack) == 0:
                found_start = True
                break

        elif char == "," and len(paren_stack) == 1:
            comma_count += 1

        pos -= 1

    if not found_start:
        return []

    # We found the start of the function call. Now look for the object before
    # this position on which we can do an 'eval' to retrieve the function
    # object.
    obj = DictionaryCompleter(lambda: globals, lambda: locals).eval_expression(
        Document(document.text, cursor_position=pos), locals
    )
    if obj is None:
        return []

    try:
        name = obj.__name__  # type:ignore
    except Exception:
        name = obj.__class__.__name__

    try:
        signature = inspect.signature(obj)  # type: ignore
    except TypeError:
        return []  # Not a callable object.
    except ValueError:
        return []  # No signature found, like for build-ins like "print".

    try:
        doc = obj.__doc__ or ""
    except:
        doc = ""

    # TODO: `index` is not yet correct when dealing with keyword-only arguments.
    return [Signature.from_inspect_signature(name, doc, signature, index=comma_count)]
