"""
Defines various Select widgets which allow choosing one or more items
from a list of options.
"""
from __future__ import annotations

import itertools
import re

from functools import partial
from types import FunctionType
from typing import (
    TYPE_CHECKING, Any, Awaitable, Callable, ClassVar, Mapping, Optional,
)

import numpy as np
import param

from bokeh.models import PaletteSelect
from bokeh.models.widgets import (
    AutocompleteInput as _BkAutocompleteInput,
    CheckboxGroup as _BkCheckboxGroup, MultiChoice as _BkMultiChoice,
    RadioGroup as _BkRadioBoxGroup,
)

from ..io.resources import CDN_DIST
from ..io.state import state
from ..layout.base import Column, ListPanel, NamedListPanel
from ..models import (
    CheckboxButtonGroup as _BkCheckboxButtonGroup,
    CustomMultiSelect as _BkMultiSelect, CustomSelect,
    RadioButtonGroup as _BkRadioButtonGroup, SingleSelect as _BkSingleSelect,
)
from ..util import PARAM_NAME_PATTERN, indexOf, isIn
from ._mixin import TooltipMixin
from .base import CompositeWidget, Widget
from .button import Button, _ButtonBase
from .input import TextAreaInput, TextInput

if TYPE_CHECKING:
    from bokeh.document import Document
    from bokeh.model import Model
    from pyviz_comms import Comm

    from ..models.widgets import DoubleClickEvent


class SelectBase(Widget):

    options = param.ClassSelector(default=[], class_=(dict, list))

    __abstract = True

    @property
    def labels(self):
        labels = []
        for o in self.options:
            if isinstance(o, param.Parameterized) and not PARAM_NAME_PATTERN.match(o.name):
                labels.append(o.name)
            else:
                labels.append(str(o))
        return labels

    @property
    def values(self):
        if isinstance(self.options, dict):
            return list(self.options.values())
        else:
            return self.options

    @property
    def _items(self):
        return dict(zip(self.labels, self.values))


class SingleSelectBase(SelectBase):

    value = param.Parameter(default=None)

    _allows_values: ClassVar[bool] = True

    _allows_none: ClassVar[bool] = False

    _supports_embed: ClassVar[bool] = True

    __abstract = True

    def __init__(self, **params):
        super().__init__(**params)
        values = self.values
        if self.value is None and None not in values and values and not self._allows_none:
            self.value = values[0]

    def _process_param_change(self, msg):
        msg = super()._process_param_change(msg)
        labels, values = self.labels, self.values
        unique = len(set(self.unicode_values)) == len(labels) and self._allows_values
        if 'value' in msg:
            val = msg['value']
            if isIn(val, values):
                unicode_values = self.unicode_values if unique else labels
                msg['value'] = unicode_values[indexOf(val, values)]
            elif values:
                self.value = self.param['value'].default if self._allows_none else self.values[0]
                if not self._allows_none:
                    del msg['value']
            else:
                self.value = self.param['value'].default
                if self._allows_none:
                    msg['value'] = self.value

        option_prop = self._property_mapping.get('options', 'options')
        is_list = isinstance(self.param['value'], param.List)
        if option_prop in msg and not is_list:
            if isinstance(self.options, dict):
                if unique and self._allows_values:
                    options = [(v, l) for l,v in zip(labels, self.unicode_values)]
                else:
                    options = labels
                msg[option_prop] = options
            else:
                msg[option_prop] = self.unicode_values
            val = self.value
            if values:
                if not isIn(val, values):
                    self.value = self.param['value'].default if self._allows_none else values[0]
            else:
                self.value = self.param['value'].default
        return msg

    @property
    def unicode_values(self):
        return [str(v) for v in self.values]

    def _process_property_change(self, msg):
        msg = super()._process_property_change(msg)
        if 'value' in msg:
            if not self.values:
                pass
            elif msg['value'] == '':
                msg['value'] = self.values[0] if self.values else None
            else:
                if isIn(msg['value'], self.unicode_values):
                    idx = indexOf(msg['value'], self.unicode_values)
                else:
                    idx = indexOf(msg['value'], self.labels)
                msg['value'] = self._items[self.labels[idx]]
        msg.pop('options', None)
        return msg

    def _get_embed_state(self, root, values=None, max_opts=3):
        if values is None:
            values = self.values
        elif any(v not in self.values for v in values):
            raise ValueError("Supplied embed states were not found "
                             f"in the {type(self).__name__} widgets values list.")
        return (self, self._models[root.ref['id']][0], values,
                lambda x: x.value, 'value', 'cb_obj.value')


class Select(SingleSelectBase):
    """
    The `Select` widget allows selecting a value from a list or dictionary of
    `options` by selecting it from a dropdown menu or selection area.

    It falls into the broad category of single-value, option-selection widgets
    that provide a compatible API and include the `RadioBoxGroup`,
    `AutocompleteInput` and `DiscreteSlider` widgets.

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

    :Example:

    >>> Select(name='Study', options=['Biology', 'Chemistry', 'Physics'])
    """

    description = param.String(default=None, doc="""
        An HTML string describing the function of this component.""")

    disabled_options = param.List(default=[], nested_refs=True, doc="""
        Optional list of ``options`` that are disabled, i.e. unusable and
        un-clickable. If ``options`` is a dictionary the list items must be
        dictionary values.""")

    groups = param.Dict(default=None, nested_refs=True, doc="""
        Dictionary whose keys are used to visually group the options
        and whose values are either a list or a dictionary of options
        to select from. Mutually exclusive with ``options``  and valid only
        if ``size`` is 1.""")

    size = param.Integer(default=1, bounds=(1, None), doc="""
        Declares how many options are displayed at the same time.
        If set to 1 displays options as dropdown otherwise displays
        scrollable area.""")

    width = param.Integer(default=300, allow_None=True, doc="""
      Width of this component. If sizing_mode is set to stretch
      or scale mode this will merely be used as a suggestion.""")

    _rename: ClassVar[Mapping[str, str | None]] = {
        'groups': None,
    }

    _source_transforms: ClassVar[Mapping[str, str | None]] = {
        'size': None, 'groups': None
    }

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

    @property
    def _widget_type(self):
        return CustomSelect if self.size == 1 else _BkSingleSelect

    def __init__(self, **params):
        super().__init__(**params)
        if self.size == 1:
            self.param.size.constant = True
        self._internal_callbacks.extend([
            self.param.watch(
                self._validate_options_groups,
                ['options', 'groups']
            ),
            self.param.watch(
                self._validate_disabled_options,
                ['options', 'disabled_options', 'value']
            ),
        ])
        self._validate_options_groups()
        self._validate_disabled_options()

    def _validate_disabled_options(self, *events):
        if self.disabled_options and self.disabled_options == self.values:
            raise ValueError(
                f'All the options of a {type(self).__name__} '
                'widget cannot be disabled.'
            )
        not_in_opts = [
            dopts
            for dopts in self.disabled_options
            if dopts not in (self.values or [])
        ]
        if not_in_opts:
            raise ValueError(
                f'Cannot disable non existing options of {type(self).__name__}: {not_in_opts}'
            )
        if len(events) == 1:
            if events[0].name == 'value' and self.value in self.disabled_options:
                raise ValueError(
                    f'Cannot set the value of {type(self).__name__} to '
                    f'{self.value!r} as it is a disabled option.'
                )
            elif events[0].name == 'disabled_options' and self.value in self.disabled_options:
                raise ValueError(
                    f'Cannot set disabled_options of {type(self).__name__} to a list that '
                    f'includes the current value {self.value!r}.'
                )
        if self.value in self.disabled_options:
            raise ValueError(
                f'Cannot initialize {type(self).__name__} with value {self.value!r} '
                'as it is one of the disabled options.'
            )

    def _validate_options_groups(self, *events):
        if self.options and self.groups:
            raise ValueError(
                f'{type(self).__name__} options and groups parameters '
                'are mutually exclusive.'
            )
        if self.size > 1 and self.groups:
            raise ValueError(
                f'{type(self).__name__} with size > 1 doe not support the'
                ' `groups` parameter, use `options` instead.'
            )

    def _process_param_change(self, msg: dict[str, Any]) -> dict[str, Any]:
        groups_provided = 'groups' in msg
        msg = super()._process_param_change(msg)
        if groups_provided or 'options' in msg and self.groups:
            groups = self.groups
            if (all(isinstance(values, dict) for values in groups.values()) is False
               and  all(isinstance(values, list) for values in groups.values()) is False):
                raise ValueError(
                    'The values of the groups dictionary must be all of '
                    'the dictionary or the list type.'
                )
            labels, values = self.labels, self.values
            unique = len(set(self.unicode_values)) == len(labels)
            if groups:
                if isinstance(next(iter(self.groups.values())), dict):
                    if unique:
                        options = {
                            group: [(str(value), label) for label, value in subd.items()]
                            for group, subd in groups.items()
                        }
                    else:
                        options = {
                            group: [str(v) for v in self.groups[group]]
                            for group in groups.keys()
                        }
                    msg['options'] = options
                else:
                    msg['options'] = {
                        group: [(str(value), str(value)) for value in values]
                        for group, values in groups.items()
                    }
            val = self.value
            if values:
                if not isIn(val, values):
                    self.value = values[0]
            else:
                self.value = None
        return msg

    @property
    def labels(self):
        if self.options:
            return super().labels
        else:
            if not self.groups:
                return {}
            else:
                return list(map(str, itertools.chain(*self.groups.values())))

    @property
    def values(self):
        if self.options:
            return super().values
        else:
            if not self.groups:
                return []
            if isinstance(next(iter(self.groups.values())), dict):
                return [v for subd in self.groups.values() for v in subd.values()]
            else:
                return list(itertools.chain(*self.groups.values()))


class NestedSelect(CompositeWidget):
    """
    The `NestedSelect` widget is composed of multiple widgets, where subsequent select options
    depend on the parent's value.

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

    :Example:

    >>> NestedSelect(
    ...     options={
    ...         "gfs": {"tmp": [1000, 500], "pcp": [1000]},
    ...         "name": {"tmp": [1000, 925, 850, 700, 500], "pcp": [1000]},
    ...     },
    ...     levels=["model", "var", "level"],
    ... )
    """

    value = param.Dict(doc="""
        The value from all the Select widgets; the keys are the levels names.
        If no levels names are specified, the keys are the levels indices.""")

    options = param.ClassSelector(class_=(dict, FunctionType), doc="""
        The options to select from. The options may be nested dictionaries, lists,
        or callables that return those types. If callables are used, the callables
        must accept `level` and `value` keyword arguments, where `level` is the
        level that updated and `value` is a dictionary of the current values, containing keys
        up to the level that was updated.""")

    layout = param.Parameter(default=Column, doc="""
        The layout type of the widgets. If a dictionary, a "type" key can be provided,
        to specify the layout type of the widgets, and any additional keyword arguments
        will be used to instantiate the layout.""")

    levels = param.List(doc="""
        Either a list of strings or a list of dictionaries. If a list of strings, the strings
        are used as the names of the levels. If a list of dictionaries, each dictionary may
        have a "name" key, which is used as the name of the level, a "type" key, which
        is used as the type of widget, and any corresponding widget keyword arguments.
        Must be specified if options is callable.""")

    disabled = param.Boolean(default=False, doc="""
        Whether the widget is disabled.""")

    _widgets = param.List(doc="The nested select widgets.")

    _max_depth = param.Integer(doc="The number of levels of the nested select widgets.")

    _levels = param.List(doc="""
        The internal rep of levels to prevent overwriting user provided levels.""")

    def __init__(self, **params):
        super().__init__(**params)
        self._update_widgets()

    def _gather_values_from_widgets(self, up_to_i=None):
        """
        Gather values from all the select widgets to update the class' value.
        """
        values = {}
        for i, select in enumerate(self._widgets):
            if up_to_i is not None and i >= up_to_i:
                break
            level = self._levels[i]
            if isinstance(level, dict):
                name = level.get("name", i)
            else:
                name = level
            values[name] = select.value if select.options else None

        return values

    def _uses_callable(self, d):
        """
        Check if the nested options has a callable.
        """
        if callable(d):
            return True

        if isinstance(d, dict):
            for value in d.values():
                if callable(value):
                    return True
                elif isinstance(value, dict):
                    return self._uses_callable(value)
        return False

    def _find_max_depth(self, d, depth=1):
        if isinstance(d, list) or d is None:
            return depth-1
        max_depth = depth
        for value in d.values():
            max_depth = max(max_depth, self._find_max_depth(value, depth + 1))
        return max_depth

    def _resolve_callable_options(self, i, options) -> dict | list:
        level = self.levels[i]
        value = self._gather_values_from_widgets(up_to_i=i)
        options = options(level=level, value=value)
        return options

    @param.depends("options", "layout", "levels", watch=True)
    def _update_widgets(self):
        """
        When options is changed, reflect changes on the select widgets.
        """
        if self._uses_callable(self.options):
            if not self.levels:
                raise ValueError("levels must be specified if options is callable")
            self._max_depth = len(self.levels)
        else:
            self._max_depth = self._find_max_depth(self.options) + 1

        if not self.levels:
            self._levels = [i for i in range(self._max_depth)]
        elif len(self.levels) != self._max_depth:
            raise ValueError(f"levels must be of length {self._max_depth}")
        else:
            self._levels = self.levels

        self._widgets = []

        # use [] as default because it's the last level if options is None
        options = (self.options or [])
        if isinstance(self.options, dict):
            options = self.options.copy()

        for i in range(self._max_depth):
            if callable(options):
                options = self._resolve_callable_options(i, options)

            value = self._init_widget(i, options)
            if isinstance(options, dict) and len(options) > 0 and value is not None:
                options = options[value]
            elif i < self._max_depth - 1 and not isinstance(options, dict):
                raise ValueError(
                    f"The level, {self.levels[i]!r} is not the last nested level, "
                    f"so it must be a dict, but got {options!r}, which is a "
                    f"{type(options).__name__}"
                )

        if isinstance(self.layout, dict):
            layout_type = self.layout.pop("type", Column)
            layout_kwargs = self.layout.copy()
        elif issubclass(self.layout, (ListPanel, NamedListPanel)):
            layout_type = self.layout
            layout_kwargs = {}
        else:
            raise ValueError(
                f"The layout must be a subclass of ListLike or dict, got {self.layout!r}."
            )

        self._composite = layout_type(*self._widgets, **layout_kwargs)
        if self.options is not None:
            self.value = self._gather_values_from_widgets()

    def _extract_level_metadata(self, i):
        """
        Extract the widget type and keyword arguments from the level metadata.
        """
        level = self._levels[i]
        if isinstance(level, int):
            return Select, {}
        elif isinstance(level, str):
            return Select, {"name": level}
        widget_type = level.get("type", Select)
        widget_kwargs = {k: v for k, v in level.items() if k != "type"}
        return widget_type, widget_kwargs

    def _lookup_value(self, i, options, values, name=None, error=False):
        """
        Look up the value of the select widget at index i or by name.
        """
        options_iterable = isinstance(options, (list, dict))
        if values is None or (options_iterable and len(options) == 0):
            value = None
        elif name is None:
            # get by index
            value = list(values.values())[i] if i < len(values) else None
        elif isinstance(self._levels[0], int):
            # get by levels keys, which are enumerations
            value = values.get(i)
        else:
            # get by levels keys, which are strings
            value = values.get(name)

        if options_iterable and options and value not in options:
            if value is not None and error:
                raise ValueError(
                    f"Failed to set value {value!r} for level {name!r}, "
                    f"must be one of {options!r}."
                )
            else:
                value = options[0]
        return value

    def _init_widget(self, i, options):
        """
        Helper method to initialize a select widget.
        """
        if isinstance(options, dict):
            options = list(options.keys())
        elif not isinstance(options, (list, dict)) and not callable(options):
            raise ValueError(
                f"options must be a dict, list, or callable that returns those types, "
                f"got {options!r}, which is a {type(options).__name__}"
            )

        widget_type, widget_kwargs = self._extract_level_metadata(i)
        value = self._lookup_value(i, options, self.value, error=False)
        widget_kwargs["options"] = options
        widget_kwargs["value"] = value
        if "visible" not in widget_kwargs:
            # first select widget always visible
            widget_kwargs["visible"] = i == 0 or callable(options) or len(options) > 0
        widget = widget_type(**widget_kwargs)
        self.link(widget, disabled="disabled")
        widget.param.watch(self._update_widget_options_interactively, "value")
        self._widgets.append(widget)
        return value

    def _update_widget_options_interactively(self, event):
        """
        When a select widget's value is changed, update to the latest options.
        """
        if self.options is None:
            return

        # little optimization to avoid looping through all the
        # widgets and updating their value
        for start_i, select in enumerate(self._widgets):  # noqa: B007
            if select is event.obj:
                break

        options = self.options if callable(self.options) else self.options.copy()

        # batch watch to prevent continuously triggering
        # this function when updating the select widgets
        with param.parameterized.batch_call_watchers(self):
            for i, select in enumerate(self._widgets[:-1]):
                if select.value is None:
                    options = {}
                    visible = False
                elif options:
                    if isinstance(options, dict):
                        if select.value in options:
                            options = options[select.value]
                        else:
                            options = options[list(options.keys())[0]]
                    visible = bool(options)

                if i < start_i:
                    # If the select widget is before the one
                    # that triggered the event,
                    # then we don't need to update it;
                    # we just need to subset options.
                    continue

                next_select = self._widgets[i + 1]
                if callable(options):
                    options = self._resolve_callable_options(i + 1, options)
                    next_options = list(options)
                elif isinstance(options, dict):
                    next_options = list(options.keys())
                elif isinstance(options, list):
                    next_options = options
                else:
                    raise NotImplementedError(
                        "options must be a dict, list, or callable that returns those types."
                    )

                next_select.param.update(
                    options=next_options,
                    visible=visible
                )
            self.value = self._gather_values_from_widgets()

    @param.depends("value", watch=True)
    def _update_options_programmatically(self):
        """
        When value is passed, update to the latest options.
        """
        if self.options is None:
            return

        # must define these or else it gets mutated in the loop
        options = self.options if callable(self.options) else self.options.copy()
        set_values = self.value.copy()
        original_values = self._gather_values_from_widgets()

        if set_values == original_values:
            return

        with param.parameterized.batch_call_watchers(self):
            try:
                for i in range(self._max_depth):
                    curr_select = self._widgets[i]
                    if callable(options):
                        options = self._resolve_callable_options(i, options)
                        curr_options = list(options)
                    elif isinstance(options, dict):
                        curr_options = list(options.keys())
                    else:
                        curr_options = options
                    curr_value = self._lookup_value(
                        i, curr_options, set_values,
                        name=curr_select.name, error=True
                    )

                    with param.discard_events(self):
                        curr_select.param.update(
                            options=curr_options,
                            value=curr_value,
                            visible=callable(curr_options) or len(curr_options) > 0
                        )
                    if curr_value is None:
                        break
                    if i < self._max_depth - 1:
                        options = options[curr_value]
            except Exception:
                # revert to original values if there is an error
                # so it's not in a limbo state
                self.value = original_values
                raise


class ColorMap(SingleSelectBase):
    """
    The `ColorMap` widget allows selecting a value from a dictionary of
    `options` each containing a colormap specified as a list of colors
    or a matplotlib colormap.

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

    :Example:

    >>> ColorMap(name='Reds', options={'Reds': ['white', 'red'], 'Blues': ['#ffffff', '#0000ff']})
    """

    options = param.Dict(default={}, doc="""
        Dictionary of colormaps""")

    ncols = param.Integer(default=1, doc="""
        Number of columns of swatches to display.""")

    swatch_height = param.Integer(default=20, doc="""
        Height of the color swatches.""")

    swatch_width = param.Integer(default=100, doc="""
        Width of the color swatches.""")

    value = param.Parameter(default=None, doc="The selected colormap.")

    value_name = param.String(default=None, doc="Name of the selected colormap.")

    _rename = {'options': 'items', 'value_name': None}

    _widget_type: ClassVar[type[Model]] = PaletteSelect

    @param.depends('value_name', watch=True, on_init=True)
    def _sync_value_name(self):
        if self.value_name and self.value_name in self.options:
            self.value = self.options[self.value_name]

    @param.depends('value', watch=True, on_init=True)
    def _sync_value(self):
        if self.value:
            idx = indexOf(self.value, self.values)
            self.value_name = self.labels[idx]

    def _process_param_change(self, params):
        if 'options' in params:
            options = []
            for name, cmap in params.pop('options').items():
                if 'matplotlib' in getattr(cmap, '__module__', ''):
                    N = getattr(cmap, 'N', 10)
                    samples = np.linspace(0, 1, N)
                    rgba_tmpl = 'rgba({0}, {1}, {2}, {3:.3g})'
                    cmap = [
                        rgba_tmpl.format(*(rgba[:3]*255).astype(int), rgba[-1])
                        for rgba in cmap(samples)
                    ]
                options.append((name, cmap))
            params['options'] = options
        if 'value' in params and not isinstance(params['value'], (str, type(None))):
            idx = indexOf(params['value'], self.values)
            params['value'] = self.labels[idx]
        return {
            self._property_mapping.get(p, p): v for p, v in params.items()
            if self._property_mapping.get(p, False) is not None
        }


class _MultiSelectBase(SingleSelectBase):

    value = param.List(default=[])

    width = param.Integer(default=300, allow_None=True, doc="""
      Width of this component. If sizing_mode is set to stretch
      or scale mode this will merely be used as a suggestion.""")

    description = param.String(default=None, doc="""
        An HTML string describing the function of this component.""")

    _supports_embed: ClassVar[bool] = False

    __abstract = True

    def _process_param_change(self, msg):
        msg = super(SingleSelectBase, self)._process_param_change(msg)
        labels, values = self.labels, self.values
        if 'value' in msg:
            msg['value'] = [labels[indexOf(v, values)] for v in msg['value']
                            if isIn(v, values)]

        if 'options' in msg:
            msg['options'] = labels
            if any(not isIn(v, values) for v in self.value):
                self.value = [v for v in self.value if isIn(v, values)]
        return msg

    def _process_property_change(self, msg):
        msg = super(SingleSelectBase, self)._process_property_change(msg)
        if 'value' in msg:
            labels = self.labels
            msg['value'] = [self._items[v] for v in msg['value']
                            if v in labels]
        msg.pop('options', None)
        return msg


class MultiSelect(_MultiSelectBase):
    """
    The `MultiSelect` widget allows selecting multiple values from a list of
    `options`.

    It falls into the broad category of multi-value, option-selection widgets
    that provide a compatible API and include the`CrossSelector`,
    `CheckBoxGroup` and `CheckButtonGroup` widgets.

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

    :Example:

    >>> MultiSelect(
    ...     name='Frameworks', value=['Bokeh', 'Panel'],
    ...     options=['Bokeh', 'Dash', 'Panel', 'Streamlit', 'Voila'], size=8
    ... )
    """

    size = param.Integer(default=4, doc="""
        The number of items displayed at once (i.e. determines the
        widget height).""")

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

    _widget_type: ClassVar[type[Model]] = _BkMultiSelect

    def __init__(self, **params):
        click_handler = params.pop('on_double_click', None)
        super().__init__(**params)
        self._dbl__click_handlers = [click_handler] if click_handler else []

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

    def _process_event(self, event: DoubleClickEvent) -> None:
        if event.option in self.labels:
            event.option = self._items[event.option]
            for handler in self._dbl__click_handlers:
                state.execute(partial(handler, event))

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

        The callback is given an `DoubleClickEvent` argument

        Example
        -------

        >>> select = pn.widgets.MultiSelect(options=["A", "B", "C"])
        >>> def handle_click(event):
        ...    print(f"Option {event.option} was double clicked.")
        >>> select.on_double_click(handle_click)

        Arguments
        ---------
        callback:
            The function to run on click events. Must accept a positional `Event` argument. Can
            be a sync or async function
        """
        self._dbl__click_handlers.append(callback)



class MultiChoice(_MultiSelectBase):
    """
    The `MultiChoice` widget allows selecting multiple values from a list of
    `options`.

    It falls into the broad category of multi-value, option-selection widgets
    that provide a compatible API and include the `MultiSelect`,
    `CrossSelector`, `CheckBoxGroup` and `CheckButtonGroup` widgets.

    The `MultiChoice` widget provides a much more compact UI than
    `MultiSelect`.

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

    :Example:

    >>> MultiChoice(
    ...     name='Favourites', value=['Panel', 'hvPlot'],
    ...     options=['Panel', 'hvPlot', 'HoloViews', 'GeoViews', 'Datashader', 'Param', 'Colorcet'],
    ...     max_items=2
    ... )
    """

    delete_button = param.Boolean(default=True, doc="""
        Whether to display a button to delete a selected option.""")

    max_items = param.Integer(default=None, bounds=(1, None), doc="""
        Maximum number of options that can be selected.""")

    option_limit = param.Integer(default=None, bounds=(1, None), doc="""
        Maximum number of options to display at once.""")

    search_option_limit = param.Integer(default=None, bounds=(1, None), doc="""
        Maximum number of options to display at once if search string is entered.""")

    placeholder = param.String(default='', doc="""
        String displayed when no selection has been made.""")

    solid = param.Boolean(default=True, doc="""
        Whether to display widget with solid or light style.""")

    width = param.Integer(default=300, allow_None=True, doc="""
      Width of this component. If sizing_mode is set to stretch
      or scale mode this will merely be used as a suggestion.""")

    _widget_type: ClassVar[type[Model]] = _BkMultiChoice


class AutocompleteInput(SingleSelectBase):
    """
    The `AutocompleteInput` widget allows selecting multiple values from a list of
    `options`.

    It falls into the broad category of multi-value, option-selection widgets
    that provide a compatible API and include the `MultiSelect`,
    `CrossSelector`, `CheckBoxGroup` and `CheckButtonGroup` widgets.

    The `MultiChoice` widget provides a much more compact UI than
    `MultiSelect`.

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

    :Example:

    >>> AutocompleteInput(
    ...     name='Study', options=['Biology', 'Chemistry', 'Physics'],
    ...     placeholder='Write your study here ...'
    ... )
    """

    case_sensitive = param.Boolean(default=True, doc="""
        Enable or disable case sensitivity.""")

    min_characters = param.Integer(default=2, doc="""
        The number of characters a user must type before
        completions are presented.""")

    placeholder = param.String(default='', doc="""
        Placeholder for empty input field.""")

    restrict = param.Boolean(default=True, doc="""
        Set to False in order to allow users to enter text that is not
        present in the list of completion strings.""")

    search_strategy = param.Selector(default='starts_with',
        objects=['starts_with', 'includes'], doc="""
        Define how to search the list of completion strings. The default option
        `"starts_with"` means that the user's text must match the start of a
        completion string. Using `"includes"` means that the user's text can
        match any substring of a completion string.""")

    value = param.Parameter(default='', allow_None=True, doc="""
      Initial or entered text value updated when <enter> key is pressed.""")

    value_input = param.String(default='', allow_None=True, doc="""
      Initial or entered text value updated on every key press.""")

    width = param.Integer(default=300, allow_None=True, doc="""
      Width of this component. If sizing_mode is set to stretch
      or scale mode this will merely be used as a suggestion.""")

    description = param.String(default=None, doc="""
        An HTML string describing the function of this component.""")

    _allows_values: ClassVar[bool] = False

    _allows_none: ClassVar[bool] = True

    _rename: ClassVar[Mapping[str, str | None]] = {'name': 'title', 'options': 'completions'}

    _widget_type: ClassVar[type[Model]] = _BkAutocompleteInput

    def _process_property_change(self, msg):
        if not self.restrict and 'value' in msg:
            try:
                return super()._process_property_change(msg)
            except Exception:
                return Widget._process_property_change(self, msg)
        return super()._process_property_change(msg)

    def _process_param_change(self, msg):
        if 'value' in msg and not self.restrict and not isIn(msg['value'], self.values):
            with param.parameterized.discard_events(self):
                props = super()._process_param_change(msg)
                self.value = props['value'] = msg['value']
        else:
            props = super()._process_param_change(msg)
        return props


class _RadioGroupBase(SingleSelectBase):

    _supports_embed = False

    _rename: ClassVar[Mapping[str, str | None]] = {
        'name': None, 'options': 'labels', 'value': 'active'
    }

    _source_transforms = {'value': "source.labels[value]"}

    _target_transforms = {'value': "target.labels.indexOf(value)"}

    __abstract = True

    def _process_param_change(self, msg):
        msg = super(SingleSelectBase, self)._process_param_change(msg)
        values = self.values
        if 'active' in msg:
            value = msg['active']
            if value in values:
                msg['active'] = indexOf(value, values)
            else:
                if self.value is not None:
                    self.value = None
                msg['active'] = None

        if 'labels' in msg:
            msg['labels'] = self.labels
            value = self.value
            if not isIn(value, values):
                self.value = None
        return msg

    def _process_property_change(self, msg):
        msg = super(SingleSelectBase, self)._process_property_change(msg)
        if 'value' in msg:
            index = msg['value']
            if index is None:
                msg['value'] = None
            else:
                msg['value'] = list(self.values)[index]
        return msg

    def _get_embed_state(self, root, values=None, max_opts=3):
        if values is None:
            values = self.values
        elif any(v not in self.values for v in values):
            raise ValueError("Supplied embed states were not found in "
                             f"the {type(self).__name__} widgets values list.")
        return (self, self._models[root.ref['id']][0], values,
                lambda x: x.active, 'active', 'cb_obj.active')



class RadioButtonGroup(_RadioGroupBase, _ButtonBase, TooltipMixin):
    """
    The `RadioButtonGroup` widget allows selecting from a list or dictionary
    of values using a set of toggle buttons.

    It falls into the broad category of single-value, option-selection widgets
    that provide a compatible API and include the `RadioBoxGroup`, `Select`,
    and `DiscreteSlider` widgets.

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

    :Example:

    >>> RadioButtonGroup(
    ...     name='Plotting library', options=['Matplotlib', 'Bokeh', 'Plotly'],
    ...     button_type='success'
    ... )
    """

    orientation = param.Selector(default='horizontal',
        objects=['horizontal', 'vertical'], doc="""
        Button group orientation, either 'horizontal' (default) or 'vertical'.""")

    _rename: ClassVar[Mapping[str, str | None]] = {**_RadioGroupBase._rename, **TooltipMixin._rename}

    _source_transforms = {
        'value': "source.labels[value]", 'button_style': None, 'description': None
    }

    _supports_embed: ClassVar[bool] = True

    _widget_type: ClassVar[type[Model]] = _BkRadioButtonGroup



class RadioBoxGroup(_RadioGroupBase):
    """
    The `RadioBoxGroup` widget allows selecting from a list or dictionary of
    values using a set of checkboxes.

    It falls into the broad category of single-value, option-selection widgets
    that provide a compatible API and include the `RadioButtonGroup`, `Select`
    and `DiscreteSlider` widgets.

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

    :Example:

    >>> RadioBoxGroup(
    ...     name='Sponsor', options=['Anaconda', 'Blackstone'], inline=True
    ... )
    """

    inline = param.Boolean(default=False, doc="""
        Whether the items be arrange vertically (``False``) or
        horizontally in-line (``True``).""")

    _supports_embed: ClassVar[bool] = True

    _widget_type: ClassVar[type[Model]] = _BkRadioBoxGroup



class _CheckGroupBase(SingleSelectBase):

    value = param.List(default=[])

    _rename: ClassVar[Mapping[str, str | None]] = {'name': None, 'options': 'labels', 'value': 'active'}

    _source_transforms = {'value': "value.map((index) => source.labels[index])"}

    _target_transforms = {'value': "value.map((label) => target.labels.indexOf(label))"}

    _supports_embed = False

    __abstract = True

    def _process_param_change(self, msg):
        msg = super()._process_param_change(msg)
        values = self.values
        if 'active' in msg:
            msg['active'] = [indexOf(v, values) for v in msg['active']
                             if isIn(v, values)]
        if 'labels' in msg:
            msg['labels'] = self.labels
            if any(not isIn(v, values) for v in self.value):
                self.value = [v for v in self.value if isIn(v, values)]
            msg["active"] = [indexOf(v, values) for v in self.value
                             if isIn(v, values)]
        msg.pop('title', None)
        return msg

    def _process_property_change(self, msg):
        msg = super(SingleSelectBase, self)._process_property_change(msg)
        if 'value' in msg:
            values = self.values
            msg['value'] = [values[a] for a in msg['value']]
        return msg



class CheckButtonGroup(_CheckGroupBase, _ButtonBase, TooltipMixin):
    """
    The `CheckButtonGroup` widget allows selecting between a list of options
    by toggling the corresponding buttons.

    It falls into the broad category of multi-option selection widgets that
    provide a compatible API and include the `MultiSelect`, `CrossSelector`
    and `CheckBoxGroup` widgets.

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

    :Example:

    >>> CheckButtonGroup(
    ...     name='Regression Models', value=['Lasso', 'Ridge'],
    ...     options=['Lasso', 'Linear', 'Ridge', 'Polynomial']
    ... )
    """

    orientation = param.Selector(default='horizontal',
        objects=['horizontal', 'vertical'], doc="""
        Button group orientation, either 'horizontal' (default) or 'vertical'.""")

    _rename: ClassVar[Mapping[str, str | None]] = {**_CheckGroupBase._rename, **TooltipMixin._rename}

    _source_transforms = {
        'value': "value.map((index) => source.labels[index])", 'button_style': None,
        'description': None
    }

    _widget_type: ClassVar[type[Model]] = _BkCheckboxButtonGroup


class CheckBoxGroup(_CheckGroupBase):
    """
    The `CheckBoxGroup` widget allows selecting between a list of options by
    ticking the corresponding checkboxes.

    It falls into the broad category of multi-option selection widgets that
    provide a compatible API and include the `MultiSelect`, `CrossSelector`
    and `CheckButtonGroup` widgets.

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

    :Example:

    >>> CheckBoxGroup(
    ...     name='Fruits', value=['Apple', 'Pear'], options=['Apple', 'Banana', 'Pear', 'Strawberry'],
    ...     inline=True
    ... )
    """

    inline = param.Boolean(default=False, doc="""
        Whether the items be arrange vertically (``False``) or
        horizontally in-line (``True``).""")

    _widget_type: ClassVar[type[Model]] = _BkCheckboxGroup



class ToggleGroup(SingleSelectBase):
    """This class is a factory of ToggleGroup widgets.

    A ToggleGroup is a group of widgets which can be switched 'on' or 'off'.

    Two types of widgets are available through the widget_type argument :
        * `'button'` (default)
        * `'box'`

    Two different behaviors are available through behavior argument:
        * 'check' (default) : boolean
           Any number of widgets can be selected. In this case value
           is a 'list' of objects.
        * 'radio' : boolean
           One and only one widget is switched on. In this case value
           is an 'object'.
    """

    _widgets_type = ['button', 'box']
    _behaviors = ['check', 'radio']

    def __new__(cls, widget_type='button', behavior='check', **params):
        if widget_type not in ToggleGroup._widgets_type:
            raise ValueError(f'widget_type {widget_type} is not valid. Valid options are {ToggleGroup._widgets_type}')
        if behavior not in ToggleGroup._behaviors:
            raise ValueError(f'behavior {widget_type} is not valid. Valid options are {ToggleGroup._behaviors}')

        if behavior == 'check':
            if widget_type == 'button':
                return CheckButtonGroup(**params)
            else:
                return CheckBoxGroup(**params)
        else:
            if isinstance(params.get('value'), list):
                raise ValueError('Radio buttons require a single value, '
                                 'found: {}'.format(params['value']))
            if widget_type == 'button':
                return RadioButtonGroup(**params)
            else:
                return RadioBoxGroup(**params)



class CrossSelector(CompositeWidget, MultiSelect):
    """
    A composite widget which allows selecting from a list of items
    by moving them between two lists. Supports filtering values by
    name to select them in bulk.

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

    :Example:

    >>> CrossSelector(
    ...     name='Fruits', value=['Apple', 'Pear'],
    ...     options=['Apple', 'Banana', 'Pear', 'Strawberry']
    ... )
    """

    width = param.Integer(default=600, allow_None=True, doc="""
        The number of options shown at once (note this is the
        only way to control the height of this widget)""")

    height = param.Integer(default=200, allow_None=True, doc="""
        The number of options shown at once (note this is the
        only way to control the height of this widget)""")

    filter_fn = param.Callable(default=re.search, doc="""
        The filter function applied when querying using the text
        fields, defaults to re.search. Function is two arguments, the
        query or pattern and the item label.""")

    size = param.Integer(default=10, doc="""
        The number of options shown at once (note this is the only way
        to control the height of this widget)""")

    definition_order = param.Integer(default=True, doc="""
       Whether to preserve definition order after filtering. Disable
       to allow the order of selection to define the order of the
       selected list.""")

    def __init__(self, **params):
        super().__init__(**params)
        # Compute selected and unselected values

        labels, values = self.labels, self.values
        selected = [
            labels[indexOf(v, values)] for v in params.get('value', [])
            if isIn(v, values)
        ]
        unselected = [k for k in labels if k not in selected]
        layout = dict(
            sizing_mode='stretch_both', margin=0
        )
        self._lists = {
            False: MultiSelect(options=unselected, size=self.size, **layout),
            True: MultiSelect(options=selected, size=self.size, **layout)
        }
        self._lists[False].param.watch(self._update_selection, 'value')
        self._lists[True].param.watch(self._update_selection, 'value')

        # Define buttons
        self._buttons = {
            False: Button(name='\u276e\u276e', width=50),
            True: Button(name='\u276f\u276f', width=50)
        }

        self._buttons[False].param.watch(self._apply_selection, 'clicks')
        self._buttons[True].param.watch(self._apply_selection, 'clicks')

        # Define search
        self._search = {
            False: TextInput(
                placeholder='Filter available options',
                margin=(0, 0, 10, 0), width_policy='max'
            ),
            True: TextInput(
                placeholder='Filter selected options',
                margin=(0, 0, 10, 0), width_policy='max'
            )
        }
        self._search[False].param.watch(self._filter_options, 'value_input')
        self._search[True].param.watch(self._filter_options, 'value_input')

        self._placeholder = TextAreaInput(
            placeholder=("To select an item highlight it on the left "
                         "and use the arrow button to move it to the right."),
            disabled=True, **layout
        )
        right = self._lists[True] if self.value else self._placeholder

        # Define Layout
        self._unselected = Column(self._search[False], self._lists[False], **layout)
        self._selected = Column(self._search[True], right, **layout)
        buttons = Column(self._buttons[True], self._buttons[False], margin=(0, 5), align='center')

        self._composite[:] = [
            self._unselected, buttons, self._selected
        ]

        self._selections = {False: [], True: []}
        self._query = {False: '', True: ''}

        self._update_disabled()
        self._update_width()

    @param.depends('width', watch=True)
    def _update_width(self):
        width = int(self.width // 2. - 50)
        self._search[False].width = width
        self._search[True].width = width
        self._lists[False].width = width
        self._lists[True].width = width

    @param.depends('size', watch=True)
    def _update_size(self):
        self._lists[False].size = self.size
        self._lists[True].size = self.size

    @param.depends('disabled', watch=True)
    def _update_disabled(self):
        self._buttons[False].disabled = self.disabled
        self._buttons[True].disabled = self.disabled

    @param.depends('value', watch=True)
    def _update_value(self):
        labels, values = self.labels, self.values
        selected = [labels[indexOf(v, values)] for v in self.value
                    if isIn(v, values)]
        unselected = [k for k in labels if k not in selected]
        self._lists[True].options = selected
        self._lists[True].value = []
        self._lists[False].options = unselected
        self._lists[False].value = []
        if len(self._lists[True].options) and self._selected[-1] is not self._lists[True]:
            self._selected[-1] = self._lists[True]
        elif not len(self._lists[True].options) and self._selected[-1] is not self._placeholder:
            self._selected[-1] = self._placeholder

    @param.depends('options', watch=True)
    def _update_options(self):
        """
        Updates the options of each of the sublists after the options
        for the whole widget are updated.
        """
        self._selections[False] = []
        self._selections[True] = []
        self._update_value()

    def _apply_filters(self):
        self._apply_query(False)
        self._apply_query(True)

    def _filter_options(self, event):
        """
        Filters unselected options based on a text query event.
        """
        selected = event.obj is self._search[True]
        self._query[selected] = event.new
        self._apply_query(selected)

    def _apply_query(self, selected):
        query = self._query[selected]
        other = self._lists[not selected].labels
        labels = self.labels
        if self.definition_order:
            options = [k for k in labels if k not in other]
        else:
            options = self._lists[selected].values
        if not query:
            self._lists[selected].options = options
            self._lists[selected].value = []
        else:
            try:
                matches = [o for o in options if self.filter_fn(query, o)]
            except Exception:
                matches = []
            self._lists[selected].options = options if options else []
            self._lists[selected].value = [m for m in matches]

    def _update_selection(self, event):
        """
        Updates the current selection in each list.
        """
        selected = event.obj is self._lists[True]
        self._selections[selected] = [v for v in event.new if v != '']

    def _apply_selection(self, event):
        """
        Applies the current selection depending on which button was
        pressed.
        """
        selected = event.obj is self._buttons[True]

        new = {k: self._items[k] for k in self._selections[not selected]}
        old = self._lists[selected].options
        other = self._lists[not selected].options

        merged = {k: k for k in list(old)+list(new)}
        leftovers = {k: k for k in other if k not in new}
        self._lists[selected].options = merged if merged else {}
        self._lists[not selected].options = leftovers if leftovers else {}
        if len(self._lists[True].options):
            self._selected[-1] = self._lists[True]
        else:
            self._selected[-1] = self._placeholder
        self.value = [self._items[o] for o in self._lists[True].options if o != '']
        self._apply_filters()

    def _get_model(self, doc, root=None, parent=None, comm=None):
        return self._composite._get_model(doc, root, parent, comm)
