"""The FunctionGui class is a Container subclass designed to represent a function.

The core `magicgui` decorator returns an instance of a FunctionGui widget.
"""
from __future__ import annotations

import inspect
import re
from collections import deque
from contextlib import contextmanager
from pathlib import Path
from types import FunctionType
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Deque,
    Generic,
    Iterable,
    Mapping,
    TypeVar,
    cast,
)

from psygnal import Signal

from magicgui._type_wrapper import resolve_forward_refs
from magicgui.application import AppRef
from magicgui.signature import MagicSignature, magic_signature
from magicgui.widgets import Container, MainWindow, ProgressBar, PushButton
from magicgui.widgets._bases.value_widget import ValueWidget
from magicgui.widgets._protocols import ContainerProtocol, MainWindowProtocol

if TYPE_CHECKING:
    from magicgui.widgets import TextEdit


def _inject_tooltips_from_docstrings(docstring: str | None, sig: MagicSignature):
    """Update ``sig`` gui options with tooltips extracted from ``docstring``."""
    from docstring_parser import parse

    if not docstring:
        return

    doc_params = {p.arg_name: p.description for p in parse(docstring).params}

    # deal with the (numpydocs) case when there are multiple parameters separated
    # by a comma
    for k, v in list(doc_params.items()):
        if "," in k:
            for split_key in k.split(","):
                doc_params[split_key.strip()] = v
            del doc_params[k]

    for name, description in doc_params.items():
        # this is to catch potentially bad arg_name parsing in docstring_parser
        # if using napoleon style google docstringss
        argname = name.split(" ", maxsplit=1)[0]
        desc = description.replace("`", "") if description else ""
        # some docstring params may be mislabeled and not appear in the params
        if argname in sig.parameters:
            # use setdefault so as not to override an explicitly provided tooltip
            sig.parameters[argname].options.setdefault("tooltip", desc)


_R = TypeVar("_R")
_KT = TypeVar("_KT")
_VT = TypeVar("_VT")


class FunctionGui(Container, Generic[_R]):
    """Wrapper for a container of widgets representing a callable object.

    Parameters
    ----------
    function : Callable
        A callable to turn into a GUI
    call_button : bool, str, or None, optional
        If True, create an additional button that calls the original function when
        clicked.  If a ``str``, set the button text. by default False when
        auto_call is True, and True otherwise.
        The button can be accessed from the ``.call_button`` property.
    layout : str, optional
        The type of layout to use. Must be one of {'horizontal', 'vertical'}.
        by default "horizontal".
    scrollable : bool, optional
        Whether to enable scroll bars or not. If enabled, scroll bars will
        only appear along the layout direction, not in both directions.
    labels : bool, optional
        Whether labels are shown in the widget. by default True
    tooltips : bool, optional
        Whether tooltips are shown when hovering over widgets. by default True
    app : magicgui.Application or str, optional
        A backend to use, by default ``None`` (use the default backend.)
    visible : bool, optional
        Whether to immediately show the widget.  If ``False``, widget is explicitly
        hidden.  If ``None``, widget is not shown, but will be shown if a parent
        container is shown, by default None.
    auto_call : bool, optional
        If True, changing any parameter in either the GUI or the widget attributes
        will call the original function with the current settings. by default False
    result_widget : bool, optional
        Whether to display a LineEdit widget the output of the function when called,
        by default False
    param_options : dict, optional
        A dict of name: widget_options dict for each parameter in the function.
        Will be passed to `magic_signature` by default ``None``
    name : str, optional
        A name to assign to the Container widget, by default `function.__name__`
    persist : bool, optional
        If `True`, when parameter values change in the widget, they will be stored to
        disk (in `~/.config/magicgui/cache`) and restored when the widget is loaded
        again with ``persist = True``.  By default, `False`.

    Raises
    ------
    TypeError
        If unexpected keyword arguments are provided
    """

    called = Signal(object)
    _widget: ContainerProtocol

    def __init__(
        self,
        function: Callable[..., _R],
        call_button: bool | str | None = None,
        layout: str = "vertical",
        scrollable: bool = False,
        labels: bool = True,
        tooltips: bool = True,
        app: AppRef = None,
        visible: bool = None,
        auto_call: bool = False,
        result_widget: bool = False,
        param_options: dict[str, dict] | None = None,
        name: str = None,
        persist: bool = False,
        **kwargs,
    ):
        if not callable(function):
            raise TypeError("'function' argument to FunctionGui must be callable.")

        # consume extra Widget keywords
        extra = set(kwargs) - {"annotation", "gui_only"}
        if extra:
            s = "s" if len(extra) > 1 else ""
            raise TypeError(f"FunctionGui got unexpected keyword argument{s}: {extra}")
        if param_options is None:
            param_options = {}
        elif not isinstance(param_options, dict):
            raise TypeError("'param_options' must be a dict of dicts")

        sig = magic_signature(function, gui_options=param_options)
        self.return_annotation = sig.return_annotation
        if tooltips:
            _inject_tooltips_from_docstrings(function.__doc__, sig)

        self.persist = persist
        self._function = function
        self.__wrapped__ = function
        # it's conceivable that function is not actually an instance of FunctionType
        # we can still support any generic callable, but we need to be careful not to
        # access attributes (like `__name__` that only function objects have).
        # Mypy doesn't seem catch this at this point:
        # https://github.com/python/mypy/issues/9934
        name = getattr(function, "__name__", None)
        if not name:
            if hasattr(function, "func"):  # partials:
                f = getattr(function, "func", None)
                name = getattr(f, "__name__", None) or str(function)
            else:
                name = f"{function.__module__}.{function.__class__}"
        self._callable_name = name

        super().__init__(
            layout=layout,
            scrollable=scrollable,
            labels=labels,
            visible=visible,
            widgets=list(sig.widgets(app).values()),
            name=name or self._callable_name,
        )
        self._param_options = param_options
        self._result_name = ""
        self._call_count: int = 0

        # a deque of Progressbars to be created by (possibly nested) tqdm_mgui iterators
        self._tqdm_pbars: Deque[ProgressBar] = deque()
        # the nesting level of tqdm_mgui iterators in a given __call__
        self._tqdm_depth: int = 0

        if call_button is None:
            call_button = not auto_call
        self._call_button: PushButton | None = None
        if call_button:
            text = call_button if isinstance(call_button, str) else "Run"
            self._call_button = PushButton(gui_only=True, text=text, name="call_button")
            if not auto_call:  # (otherwise it already gets called)

                @self._call_button.changed.connect
                def _disable_button_and_call():
                    # disable the call button until the function has finished
                    self._call_button = cast(PushButton, self._call_button)
                    self._call_button.enabled = False
                    try:
                        self.__call__()
                    finally:
                        self._call_button.enabled = True

            self.append(self._call_button)

        self._result_widget: ValueWidget | None = None
        if result_widget:
            from magicgui.widgets._bases import create_widget

            self._result_widget = create_widget(
                value=None,
                annotation=self._return_annotation,
                gui_only=True,
                is_result=True,
            )
            self.append(self._result_widget)

        if persist:
            self._load(quiet=True)

        self._auto_call = auto_call
        self.changed.connect(self._on_change)

    def _on_change(self):
        if self.persist:
            self._dump()
        if self._auto_call:
            self()

    @property
    def call_button(self) -> PushButton | None:
        """Return the call button."""
        return self._call_button

    @property
    def call_count(self) -> int:
        """Return the number of times the function has been called."""
        return self._call_count

    def reset_call_count(self) -> None:
        """Reset the call count to 0."""
        self._call_count = 0

    @property
    def return_annotation(self):
        """Return annotation to use when converting to :class:`inspect.Signature`.

        ForwardRefs will be resolve when setting the annotation.
        """
        return self._return_annotation

    @return_annotation.setter
    def return_annotation(self, value):
        self._return_annotation = resolve_forward_refs(value)

    @property
    def __signature__(self) -> MagicSignature:
        """Return a MagicSignature object representing the current state of the gui."""
        return super().__signature__.replace(return_annotation=self.return_annotation)

    def __call__(self, *args: Any, update_widget: bool = False, **kwargs: Any) -> _R:
        """Call the original function with the current parameter values from the Gui.

        It is also possible to override the current parameter values from the GUI by
        providing args/kwargs to the function call.  Only those provided will override
        the ones from the gui.  A `called` signal will also be emitted with the results.

        Returns
        -------
        result : Any
            whatever the return value of the original function would have been.

        Examples
        --------
        gui = FunctionGui(func, show=True)

        # then change parameters in the gui, or by setting:  gui.param.value = something

        gui()  # calls the original function with the current parameters
        """
        sig = self.__signature__
        try:
            bound = sig.bind(*args, **kwargs)
        except TypeError as e:
            if "missing a required argument" not in str(e):
                raise

            match = re.search("argument: '(.+)'", str(e))
            missing = match.groups()[0] if match else "<param>"
            msg = (
                f"{e} in call to '{self._callable_name}{sig}'.\n"
                "To avoid this error, you can bind a value or callback to the "
                f"parameter:\n\n    {self._callable_name}.{missing}.bind(value)"
                "\n\nOr use the 'bind' option in the magicgui decorator:\n\n"
                f"    @magicgui({missing}={{'bind': value}})\n"
                f"    def {self._callable_name}{sig}: ..."
            )
            raise TypeError(msg) from None

        if update_widget:
            self._auto_call, before = False, self._auto_call
            try:
                self.update(bound.arguments)
            finally:
                self._auto_call = before

        bound.apply_defaults()

        self._tqdm_depth = 0  # reset the tqdm stack count
        with _function_name_pointing_to_widget(self):
            value = self._function(*bound.args, **bound.kwargs)

        self._call_count += 1
        if self._result_widget is not None:
            with self._result_widget.changed.blocked():
                self._result_widget.value = value

        return_type = sig.return_annotation
        if return_type:
            from magicgui.type_map import _type2callback

            for callback in _type2callback(return_type):
                callback(self, value, return_type)
        self.called.emit(value)
        return value

    def __repr__(self) -> str:
        """Return string representation of instance."""
        return f"<{type(self).__name__} {self._callable_name}{self.__signature__}>"

    def update(
        self,
        mapping: Mapping | Iterable[tuple[str, Any]] | None = None,
        **kwargs: Any,
    ):
        """Update the parameters in the widget from a mapping, iterable, or kwargs."""
        with self.changed.blocked():
            if mapping:
                items = mapping.items() if isinstance(mapping, Mapping) else mapping
                for key, value in items:
                    getattr(self, key).value = value
            for key, value in kwargs.items():
                getattr(self, key).value = value
        self.changed.emit()

    @property
    def result_name(self) -> str:
        """Return a name that can be used for the result of this magicfunction."""
        return self._result_name or (self._callable_name + " result")

    @result_name.setter
    def result_name(self, value: str):
        """Set the result name of this FunctionGui widget."""
        self._result_name = value

    def copy(self) -> FunctionGui:
        """Return a copy of this FunctionGui."""
        return FunctionGui(
            function=self._function,
            call_button=self._call_button.text if self._call_button else None,
            layout=self.layout,
            labels=self.labels,
            param_options=self._param_options,
            auto_call=self._auto_call,
            result_widget=bool(self._result_widget),
            app=None,
        )

    _bound_instances: dict[int, FunctionGui] = {}

    def __get__(self, obj, objtype=None) -> FunctionGui:
        """Provide descriptor protocol.

        This allows the @magicgui decorator to work on a function as well as a method.
        If a method on a class is decorated with `@magicgui`, then accessing the
        attribute on an instance of that class will return a version of the FunctionGui
        in which the first argument of the function is bound to the instance. (Just like
        what you'd expect with the @property decorator.)

        Example
        -------
        >>> class MyClass:
        ...     @magicgui
        ...     def my_method(self, x=1):
        ...         print(locals())
        ...
        >>> c = MyClass()
        >>> c.my_method  # the FunctionGui that can be used as a widget
        >>> c.my_method(x=34)  # calling it works as usual, with `c` provided as `self`
        {'self': <__main__.MyClass object at 0x7fb610e455e0>, 'x': 34}
        """
        if obj is None:
            return self
        obj_id = id(obj)
        if obj_id not in self._bound_instances:
            method = getattr(obj.__class__, self._function.__name__)
            p0 = list(inspect.signature(method).parameters)[0]
            prior, self._param_options = self._param_options, {
                p0: {"bind": obj},
                **self._param_options,
            }
            try:
                self._bound_instances[obj_id] = self.copy()
            finally:
                self._param_options = prior
        return self._bound_instances[obj_id]

    def __set__(self, obj, value):
        """Prevent setting a magicgui attribute."""
        raise AttributeError("Can't set magicgui attribute")

    @property
    def _dump_path(self) -> Path:
        from .._util import user_cache_dir

        name = getattr(self._function, "__qualname__", self._callable_name)
        name = name.replace("<", "-").replace(">", "-")  # e.g. <locals>
        return user_cache_dir() / f"{self._function.__module__}.{name}"

    def _dump(self, path=None):
        super()._dump(path or self._dump_path)

    def _load(self, path=None, quiet=False):
        super()._load(path or self._dump_path, quiet=quiet)


class MainFunctionGui(FunctionGui[_R], MainWindow):
    """Container of widgets as a Main Application Window."""

    _widget: MainWindowProtocol

    def __init__(self, function: Callable, *args, **kwargs):
        super().__init__(function, *args, **kwargs)
        self.create_menu_item("Help", "Documentation", callback=self._show_docs)
        self._help_text_edit: TextEdit | None = None

    def _show_docs(self):
        if not self._help_text_edit:
            from magicgui.widgets import TextEdit

            docs = self._function.__doc__
            html = _docstring_to_html(docs) if docs else "None"
            self._help_text_edit = TextEdit(value=html)
            self._help_text_edit.read_only = True
            self._help_text_edit.width = 600
            self._help_text_edit.height = 400

        self._help_text_edit.show()


def _docstring_to_html(docs: str) -> str:
    """Convert docstring into rich text html."""
    from docstring_parser import parse

    ds = parse(docs)

    ptemp = "<li><p><strong>{}</strong> (<em>{}</em>) - {}</p></li>"
    plist = [ptemp.format(p.arg_name, p.type_name, p.description) for p in ds.params]
    params = "<h3>Parameters</h3><ul>{}</ul>".format("".join(plist))
    short = f"<p>{ds.short_description}</p>" if ds.short_description else ""
    long = f"<p>{ds.long_description}</p>" if ds.long_description else ""
    return re.sub(r"``?([^`]+)``?", r"<code>\1</code>", f"{short}{long}{params}")


_UNSET = object()


@contextmanager
def _function_name_pointing_to_widget(function_gui: FunctionGui):
    """Context in which the name of the function points to the function_gui instance.

    When calling the function provided to FunctionGui, we make sure that the name
    of the function points to the FunctionGui object itself.
    In standard ``@magicgui`` usage, this will have been the case anyway.
    Doing this here allows the function name in a ``@magic_factory``-decorated function
    to *also* refer to the function gui instance created by the factory, (rather than
    to the :class:`~magicgui._magicgui.MagicFactory` object).

    Examples
    --------
    >>> @magicgui
    >>> def func():
    ...     # using "func" in the body here will refer to the widget.
    ...     print(type(func))
    >>>
    >>> func()  # prints 'magicgui.widgets._function_gui.FunctionGui'

    >>> @magic_factory
    >>> def func():
    ...     # using "func" in the body here will refer to the *widget* not the factory.
    ...     print(type(func))
    >>>
    >>> widget = func()
    >>> widget()  # *also* prints 'magicgui.widgets._function_gui.FunctionGui'
    """
    function = function_gui._function
    if not isinstance(function, FunctionType):
        # it's not a function object, so we don't know how to patch it...
        yield
        return

    func_name = function.__name__
    # see https://docs.python.org/3/library/inspect.html for details on code objects
    code = function.__code__

    if func_name in code.co_names:
        # This indicates that the function name was used inside the body of the
        # function, and points to some object in the module's global namespace.
        # function.__globals__ here points to the module-level globals in which the
        # function was defined.
        original_value = function.__globals__.get(func_name)
        function.__globals__[func_name] = function_gui
        try:
            yield
        finally:
            function.__globals__[func_name] = original_value

    elif function.__closure__ and func_name in code.co_freevars:
        # This indicates that the function name was used inside the body of the
        # function, and points to some object defined in a local scope (closure), rather
        # than the module's global namespace.
        # the position of the function name in code.co_freevars tells us where to look
        # for the value in the function.__closure__ tuple.
        idx = code.co_freevars.index(func_name)
        original_value = function.__closure__[idx].cell_contents
        function.__closure__[idx].cell_contents = function_gui
        try:
            yield
        finally:
            function.__closure__[idx].cell_contents = original_value
    else:
        yield
