"""
Defines the Button and button-like widgets which allow triggering
events or merely toggling between on-off states.
"""
from __future__ import annotations

import typing as t
import warnings

import param

from bokeh.events import ButtonClick, MenuItemClick
from bokeh.models import Dropdown as _BkDropdown, Toggle as _BkToggle
from bokeh.models.ui import SVGIcon, TablerIcon

from ..io.resources import CDN_DIST
from ..links import Callback
from ..models.widgets import Button as _BkButton
from ..util.warnings import find_stack_level
from ._mixin import TooltipMixin
from .base import Widget

if t.TYPE_CHECKING:
    from collections.abc import Awaitable, Callable, Mapping

    from bokeh.document import Document
    from bokeh.model import Model
    from pyviz_comms import Comm

    from ..links import JSLinkTarget, Link


BUTTON_TYPES: list[str] = ['default', 'primary', 'success', 'warning', 'danger', 'light']
BUTTON_STYLES: list[str] = ['solid', 'outline']


def _normalize_color_button_type_constructor_params(params: dict[str, t.Any]) -> None:
    if "color" in params and "button_type" in params:
        warnings.warn(
            "Both 'color' and 'button_type' were provided; using 'color' "
            "and ignoring 'button_type'. Note that 'button_type' is deprecated and 'color' "
            "should be preferred. 'button_type' will be removed in version 2.0.",
            PendingDeprecationWarning,
            stacklevel=find_stack_level(),
        )
        params.pop("button_type")
    elif "button_type" in params:
        warnings.warn(
            "button_type is deprecated and will be removed in version 2.0. Use 'color' instead.",
            PendingDeprecationWarning,
            stacklevel=find_stack_level(),
        )
        params["color"] = params["button_type"]
    elif "color" in params:
        params["button_type"] = params["color"]


def _normalize_variant_button_style_constructor_params(params: dict[str, t.Any]) -> None:
    if "variant" in params and "button_style" in params:
        warnings.warn(
            "Both 'variant' and 'button_style' were provided; using 'variant' "
            "and ignoring 'button_style'. Note that 'button_style' is deprecated and 'color' "
            "should be preferred. 'button_style' will be removed in version 2.0.",
            PendingDeprecationWarning,
            stacklevel=find_stack_level(),
        )
        params.pop("button_style")
    elif "button_style" in params:
        warnings.warn(
            "button_style is deprecated and will be removed in version 2.0. Use 'variant' instead.",
            PendingDeprecationWarning,
            stacklevel=find_stack_level(),
        )
        params["variant"] = params["button_style"]
    elif "variant" in params:
        params["button_style"] = params["variant"]


def _normalize_button_appearance_constructor_params(params: dict[str, t.Any]) -> None:
    _normalize_color_button_type_constructor_params(params)
    _normalize_variant_button_style_constructor_params(params)


def _merge_color_button_type_aliases_in_param_change(params: dict[str, t.Any]) -> None:
    if "color" in params and "button_type" in params:
        params.pop("button_type")
    elif "button_type" in params:
        params["color"] = params.pop("button_type")


def _merge_variant_button_style_aliases_in_param_change(params: dict[str, t.Any]) -> None:
    if "variant" in params and "button_style" in params:
        params.pop("button_style")
    elif "button_style" in params:
        params["variant"] = params.pop("button_style")


def _merge_button_appearance_aliases_in_param_change(params: dict[str, t.Any]) -> None:
    _merge_color_button_type_aliases_in_param_change(params)
    _merge_variant_button_style_aliases_in_param_change(params)


def _sync_color_button_type_alias_post_init(owner: param.Parameterized) -> None:
    if owner.color != owner.button_type:  # type: ignore[attr-defined]
        owner.button_type = owner.color  # type: ignore[attr-defined]


def _sync_variant_button_style_alias_post_init(owner: param.Parameterized) -> None:
    if owner.variant != owner.button_style:  # type: ignore[attr-defined]
        owner.button_style = owner.variant  # type: ignore[attr-defined]


def _sync_button_appearance_aliases_post_init(owner: param.Parameterized) -> None:
    _sync_color_button_type_alias_post_init(owner)
    _sync_variant_button_style_alias_post_init(owner)


def _register_color_button_type_sync_watchers(owner: param.Parameterized) -> None:
    def _sync_color_from_button_type(event: param.parameterized.Event) -> None:
        self = event.obj
        if self.color != self.button_type and self.button_type is not None:  # type: ignore[attr-defined]
            self.color = self.button_type  # type: ignore[attr-defined]

    def _sync_button_type_from_color(event: param.parameterized.Event) -> None:
        self = event.obj
        if self.button_type != self.color:  # type: ignore[attr-defined]
            self.button_type = self.color  # type: ignore[attr-defined]

    owner._internal_callbacks.extend([
        owner.param.watch(_sync_color_from_button_type, ['button_type']),
        owner.param.watch(_sync_button_type_from_color, ['color']),
    ])


def _register_variant_button_style_sync_watchers(owner: param.Parameterized) -> None:
    def _sync_variant_from_button_style(event: param.parameterized.Event) -> None:
        self = event.obj
        if self.variant != self.button_style and self.button_style is not None:  # type: ignore[attr-defined]
            self.variant = self.button_style  # type: ignore[attr-defined]

    def _sync_button_style_from_variant(event: param.parameterized.Event) -> None:
        self = event.obj
        if self.button_style != self.variant:  # type: ignore[attr-defined]
            self.button_style = self.variant  # type: ignore[attr-defined]

    owner._internal_callbacks.extend([
        owner.param.watch(_sync_variant_from_button_style, ['button_style']),
        owner.param.watch(_sync_button_style_from_variant, ['variant']),
    ])


def _register_button_appearance_sync_watchers(owner: param.Parameterized) -> None:
    _register_color_button_type_sync_watchers(owner)
    _register_variant_button_style_sync_watchers(owner)


class _ButtonBase(Widget):

    button_type: t.Literal[
        'default', 'primary', 'success', 'warning', 'danger', 'light'
    ] = param.Selector(default='default', objects=BUTTON_TYPES, doc="""
        A button theme; should be one of 'default' (white), 'primary'
        (blue), 'success' (green), 'info' (yellow), 'light' (light),
        or 'danger' (red). The same value is exposed as ``color``.""")  # type: ignore[assignment, ty:invalid-assignment]

    button_style: t.Literal['solid', 'outline'] = param.Selector(
        default='solid', objects=BUTTON_STYLES, doc="""
        A button style to switch between 'solid', 'outline'. The same value is
        exposed as ``variant``.""")  # type: ignore[assignment, ty:invalid-assignment]

    color: t.Literal[
        'default', 'primary', 'success', 'warning', 'danger', 'light'
    ] = param.Selector(default='default', objects=BUTTON_TYPES, doc="""
        Semantic color of the button; alias for ``button_type``.""")  # type: ignore[assignment, ty:invalid-assignment]

    variant: t.Literal['solid', 'outline'] = param.Selector(
        default='solid', objects=BUTTON_STYLES, doc="""
        Visual variant of the button; alias for ``button_style`` ('solid' or
        'outline').""")  # type: ignore[assignment, ty:invalid-assignment]

    _rename: t.ClassVar[Mapping[str, str | None]] = {
        **Widget._rename,
        'label': 'label',
        'button_style': None,
        'color': 'button_type',
        'variant': None,
    }

    _source_transforms: t.ClassVar[Mapping[str, str | None]] = {
        'button_style': None, 'color': None, 'variant': None,
    }

    _stylesheets: t.ClassVar[list[str]] = [f'{CDN_DIST}css/button.css']

    __abstract = True

    def __init__(self, **params: t.Any):
        _normalize_button_appearance_constructor_params(params)
        super().__init__(**params)
        _register_button_appearance_sync_watchers(self)
        _sync_button_appearance_aliases_post_init(self)

    def _process_param_change(self, params):
        params = dict(params)
        _merge_button_appearance_aliases_in_param_change(params)
        if 'variant' in params or 'css_classes' in params:
            params['css_classes'] = [
                params.pop('variant', self.variant)
            ] + params.get('css_classes', self.css_classes)
        return super()._process_param_change(params)


class IconMixin(Widget):

    icon = param.String(default=None, doc="""
        An icon to render to the left of the button label. Either an SVG or an
        icon name which is loaded from https://tabler-icons.io.""")

    icon_size = param.String(default='1em', doc="""
        Size of the icon as a string, e.g. 12px or 1em.""")

    _rename: t.ClassVar[Mapping[str, str | None]] = {
        'icon_size': None, '_icon': 'icon', 'icon': None
    }

    __abstract = True

    def _process_param_change(self, params):
        icon_size = params.pop('icon_size', self.icon_size)
        if params.get('icon') is not None:
            icon = params['icon']
            if icon.lstrip().startswith('<svg'):
                icon_model = SVGIcon(svg=icon, size=icon_size)
            else:
                icon_model = TablerIcon(icon_name=icon, size=icon_size)
            params['_icon'] = icon_model
        return super()._process_param_change(params)


class _ClickButton(Widget):

    __abstract = True

    _event: t.ClassVar[str] = 'button_click'

    def _get_model(
        self, doc: Document, root: Model | None = None,
        parent: Model | None = None, comm: Comm | None = None
    ) -> Model:
        model = super()._get_model(doc, root, parent, comm)
        self._register_events(self._event, model=model, doc=doc, comm=comm)
        return model

    def js_on_click(self, args: dict[str, t.Any] = {}, code: str = "") -> Callback:
        """
        Allows defining a JS callback to be triggered when the button
        is clicked.

        Parameters
        -----------
        args: dict
          A mapping of objects to make available to the JS callback
        code: str
          The Javascript code to execute when the button is clicked.

        Returns
        -------
        callback: Callback
          The Callback which can be used to disable the callback.
        """
        from ..links import Callback
        return Callback(self, code={'event:'+self._event: code}, args=args)

    def jscallback(self, args: dict[str, t.Any] = {}, **callbacks: str) -> Callback:
        """
        Allows defining a Javascript (JS) callback to be triggered when a property
        changes on the source object. The keyword arguments define the
        properties that trigger a callback and the JS code that gets
        executed.

        Parameters
        -----------
        args: dict
          A mapping of objects to make available to the JS callback
        **callbacks: dict
          A mapping between properties on the source model and the code
          to execute when that property changes

        Returns
        -------
        callback: Callback
          The Callback which can be used to disable the callback.
        """
        for k, v in list(callbacks.items()):
            if k == 'clicks':
                k = 'event:'+self._event
            val = self._rename.get(v, v)
            if val is not None:
                callbacks[k] = val
        return Callback(self, code=callbacks, args=args)


class Button(_ButtonBase, _ClickButton, IconMixin, TooltipMixin):
    """
    The `Button` widget allows triggering events when the button is
    clicked.

    The Button provides a `value` parameter, which will toggle from
    `False` to `True` while the click event is being processed

    It also provides an additional `clicks` parameter, that can be
    watched to subscribe to click events.

    Reference: https://panel.holoviz.org/reference/widgets/Button.html#widgets-gallery-button

    :Example:

    >>> pn.widgets.Button(name='Click me', icon='caret-right', color='primary')
    """

    clicks = param.Integer(default=0, doc="""
        Number of clicks (can be listened to)""")

    value = param.Event(doc="""
        Toggles from False to True while the event is being processed.""")

    _rename: t.ClassVar[Mapping[str, str | None]] = {
        **_ButtonBase._rename,
        **IconMixin._rename,
        **TooltipMixin._rename,
        'clicks': None,
        'value': None,
    }

    _source_transforms: t.ClassVar[Mapping[str, str | None]] = {
        'button_style': None, 'color': None, 'variant': None, 'description': None,
    }

    _target_transforms: t.ClassVar[Mapping[str, str | None]] = {
        'event:button_click': None, 'value': None,
    }

    _widget_type: t.ClassVar[type[Model]] = _BkButton

    def __init__(self, **params):
        click_handler = params.pop('on_click', None)
        super().__init__(**params)
        if click_handler:
            self.on_click(click_handler)

    @property
    def _linkable_params(self) -> list[str]:
        return super()._linkable_params + ['value']

    def jslink(
        self, target: JSLinkTarget, code: dict[str, str] | None = None,
        args: dict[str, t.Any] | None = None, bidirectional: bool = False,
        **links: str
    ) -> Link:
        """
        Links properties on the this Button to those on the
        `target` object in Javascript (JS) code.

        Supports two modes, either specify a
        mapping between the source and target model properties as
        keywords or provide a dictionary of JS code snippets which
        maps from the source parameter to a JS code snippet which is
        executed when the property changes.

        Parameters
        -----------
        target: panel.viewable.Viewable | bokeh.model.Model | holoviews.core.dimension.Dimensioned
          The target to link the value(s) to.
        code: dict
          Custom code which will be executed when the widget value
          changes.
        args: dict
          A mapping of objects to make available to the JS callback
        bidirectional: boolean
          Whether to link source and target bi-directionally. Default is `False`.
        **links: dict[str,str]
          A mapping between properties on the source model and the
          target model property to link it to.

        Returns
        -------
        Link
          The Link can be used unlink the widget and the target model.
        """
        links = {'event:'+self._event if p == 'value' else p: v for p, v in links.items()}
        return super().jslink(target, code, args, bidirectional, **links)

    def _process_event(self, event: ButtonClick) -> None:
        self.param.trigger('value')
        self.clicks += 1

    def on_click(
        self, callback: Callable[[param.parameterized.Event], None | Awaitable[None]]
    ) -> param.parameterized.Watcher:
        """
        Register a callback to be executed when the `Button` is clicked.

        The callback is given an `Event` argument declaring the number of clicks

        Example
        -------

        >>> button = pn.widgets.Button(name='Click me')
        >>> def handle_click(event):
        ...    print("I was clicked!")
        >>> button.on_click(handle_click)

        Parameters
        ----------
        callback:
            The function to run on click events. Must accept a positional `Event` argument. Can
            be a sync or async function

        Returns
        -------
        watcher: param.Parameterized.Watcher
          A `Watcher` that executes the callback when the button is clicked.
        """
        return self.param.watch(callback, 'clicks', onlychanged=False)


class Toggle(_ButtonBase, IconMixin):
    """The `Toggle` widget allows toggling a single condition between `True`/`False` states.

    This widget is interchangeable with the `Checkbox` widget.

    Reference: https://panel.holoviz.org/reference/widgets/Toggle.html

    :Example:

    >>> Toggle(name='Toggle', color='success')
    """

    value = param.Boolean(default=False, doc="""
        Whether the button is currently toggled.""")

    _rename: t.ClassVar[Mapping[str, str | None]] = {
        **TooltipMixin._rename,
        **IconMixin._rename,
        **_ButtonBase._rename,
        'value': 'active',
    }

    _supports_embed: bool = True

    _widget_type: t.ClassVar[type[Model]] = _BkToggle

    def _get_embed_state(self, root, values=None, max_opts=3):
        return (self, self._models[root.ref['id']][0], [False, True],
                lambda x: x.active, 'active', 'cb_obj.active')


class MenuButton(_ButtonBase, _ClickButton, IconMixin):
    """
    The `MenuButton` widget allows specifying a list of menu items to
    select from triggering events when the button is clicked.

    Unlike other widgets, it does not have a `value`
    parameter. Instead it has a `clicked` parameter that can be
    watched to trigger events and which reports the last clicked menu
    item.

    Reference: https://panel.holoviz.org/reference/widgets/MenuButton.html

    :Example:

    >>> menu_items = [('Option A', 'a'), ('Option B', 'b'), None, ('Option C', 'c')]
    >>> MenuButton(name='Dropdown', items=menu_items, color='primary')
    """

    clicked = param.String(default=None, doc="""
      Last menu item that was clicked.""")

    items: list[str | tuple[str, str] | None] = param.List(
        default=[], item_type=(str, tuple, type(None)), doc="""
      Menu items in the dropdown. Allows strings, tuples of the form
      (title, value) or Nones to separate groups of items."""
    )  # type: ignore[assignment, ty:invalid-assignment]

    split = param.Boolean(default=False, doc="""
      Whether to add separate dropdown area to button.""")

    _event: t.ClassVar[str] = 'menu_item_click'

    _rename: t.ClassVar[Mapping[str, str | None]] = {
        **IconMixin._rename,
        **_ButtonBase._rename,
        'clicked': None,
        'items': 'menu',
        'value': None
    }

    _widget_type: t.ClassVar[type[Model]] = _BkDropdown

    def __init__(self, **params):
        click_handler = params.pop('on_click', None)
        super().__init__(**params)
        if click_handler:
            self.on_click(click_handler)

    def _get_model(
        self, doc: Document, root: Model | None = None,
        parent: Model | None = None, comm: Comm | None = None
    ) -> Model:
        model = super()._get_model(doc, root, parent, comm)
        self._register_events('button_click', model=model, doc=doc, comm=comm)
        return model

    def _process_event(self, event: ButtonClick | MenuItemClick):
        if isinstance(event, MenuItemClick):
            item = event.item
        elif isinstance(event, ButtonClick):
            item = self.label
        self.clicked = item

    def on_click(
        self, callback: Callable[[param.parameterized.Event], None]
    ) -> param.parameterized.Watcher:
        """
        Register a callback to be executed when the button is clicked.

        The callback is given an `Event` argument declaring the number of clicks

        Parameters
        ----------
        callback: (Callable[[param.parameterized.Event], None])
            The function to run on click events. Must accept a positional `Event` argument

        Returns
        -------
        watcher: param.Parameterized.Watcher
          A `Watcher` that executes the callback when the MenuButton is clicked.
        """
        return self.param.watch(callback, 'clicked', onlychanged=False)
