# Copyright 2024 Marimo. All rights reserved.
from __future__ import annotations

import base64
import dataclasses
import os
import pathlib
import sys
import traceback
from dataclasses import dataclass
from typing import (
    Any,
    Callable,
    Dict,
    Final,
    List,
    Literal,
    Optional,
    Sequence,
    Tuple,
    TypeVar,
    Union,
    cast,
)

from marimo import _loggers
from marimo._data.series import (
    DataFrameSeries,
    get_category_series_info,
    get_number_series_info,
)
from marimo._dependencies.dependencies import DependencyManager
from marimo._output.rich_help import mddoc
from marimo._plugins.core.web_component import JSONType
from marimo._plugins.ui._core.ui_element import S as JSONTypeBound, UIElement
from marimo._plugins.validators import (
    validate_between_range,
    validate_range,
    warn_js_safe_number,
)
from marimo._runtime.functions import Function
from marimo._server.files.os_file_system import OSFileSystem
from marimo._server.models.files import FileInfo

LOGGER = _loggers.marimo_logger()

Numeric = Union[int, float]


@mddoc
class number(UIElement[Optional[Numeric], Optional[Numeric]]):
    """
    A number picker over an interval.

    **Example.**

    ```python
    number = mo.ui.number(start=1, stop=10, step=2)
    ```

    Or for integer-only values:

    ```python
    number = mo.ui.number(step=1)
    ```

    Or from a dataframe series:

    ```python
    number = mo.ui.number.from_series(df["column_name"])
    ```

    **Attributes.**

    - `value`: the value of the number, possibly `None`
    - `start`: the minimum value of the interval
    - `stop`: the maximum value of the interval
    - `step`: the number increment

    **Initialization Args.**

    - `start`: optional, the minimum value of the interval
    - `stop`: optional, the maximum value of the interval
    - `step`: the number increment
    - `value`: default value
    - `debounce`: whether to debounce (rate-limit) value
        updates from the frontend
    - `label`: markdown label for the element
    - `on_change`: optional callback to run when this element's value changes
    - `full_width`: whether the input should take up the full width of its
        container
    """

    _name: Final[str] = "marimo-number"

    def __init__(
        self,
        start: Optional[float] = None,
        stop: Optional[float] = None,
        step: Optional[float] = None,
        value: Optional[float] = None,
        debounce: bool = False,
        *,
        label: str = "",
        on_change: Optional[Callable[[Optional[Numeric]], None]] = None,
        full_width: bool = False,
    ) -> None:
        validate_range(min_value=start, max_value=stop)
        validate_between_range(value, min_value=start, max_value=stop)
        warn_js_safe_number(start, stop, value)

        # Set value to min or max if None
        if value is None:
            if start is not None:
                value = start
            elif stop is not None:
                value = stop

        # Lower bound
        self.start = start
        # Upper bound
        self.stop = stop
        # Increment size
        self.step = step
        super().__init__(
            component_name=number._name,
            initial_value=value,
            label=label,
            args={
                "start": start,
                "stop": stop,
                "step": step if step is not None else None,
                "debounce": debounce,
                "full-width": full_width,
            },
            on_change=on_change,
        )

    @staticmethod
    def from_series(series: DataFrameSeries, **kwargs: Any) -> "number":
        """Create a number picker from a dataframe series."""
        info = get_number_series_info(series)
        start = kwargs.pop("start", info.min)
        stop = kwargs.pop("stop", info.max)
        label = kwargs.pop("label", info.label)
        return number(start=start, stop=stop, label=label, **kwargs)

    def _convert_value(self, value: Optional[Numeric]) -> Optional[Numeric]:
        """Value is `None` if user uses keyboard to delete contents of input"""
        return value


@mddoc
class slider(UIElement[Numeric, Numeric]):
    """
    A numeric slider over an interval.

    **Example.**

    ```python
    slider = mo.ui.slider(start=1, stop=10, step=2)
    ```

    Or from a dataframe series:

    ```python
    slider = mo.ui.slider.from_series(df["column_name"])
    ```

    Or using numpy arrays:

    ```python
    import numpy as np

    # linear steps
    steps = np.array([1, 2, 3, 4, 5])
    slider = mo.ui.slider(steps=steps)
    # log steps
    log_slider = mo.ui.slider(steps=np.logspace(0, 3, 4))
    # power steps
    power_slider = mo.ui.slider(steps=np.power([1, 2, 3], 2))
    ```

    **Attributes.**

    - `value`: the current numeric value of the slider
    - `start`: the minimum value of the interval
    - `stop`: the maximum value of the interval
    - `step`: the slider increment
    - `steps`: list of steps

    **Initialization Args.**

    - `start`: the minimum value of the interval
    - `stop`: the maximum value of the interval
    - `step`: the slider increment
    - `value`: default value
    - `debounce`: whether to debounce the slider to only send
        the value on mouse-up or drag-end
    - `orientation`: the orientation of the slider,
        either "horizontal" or "vertical"
    - `show_value`: whether to display the current value of the slider
    - `steps`: list of steps to customize the slider, mutually exclusive
        with `start`, `stop`, and `step`
    - `label`: markdown label for the element
    - `on_change`: optional callback to run when this element's value changes
    - `full_width`: whether the input should take up the full width of its
        container
    """

    _name: Final[str] = "marimo-slider"
    _mapping: Optional[Dict[int, Numeric]] = None

    def __init__(
        self,
        start: Optional[Numeric] = None,
        stop: Optional[Numeric] = None,
        step: Optional[Numeric] = None,
        value: Optional[Numeric] = None,
        debounce: bool = False,
        orientation: Literal["horizontal", "vertical"] = "horizontal",
        show_value: bool = False,
        steps: Optional[Sequence[Numeric]] = None,
        *,
        label: str = "",
        on_change: Optional[Callable[[Optional[Numeric]], None]] = None,
        full_width: bool = False,
    ) -> None:
        self.start: Numeric
        self.stop: Numeric
        self.step: Optional[Numeric]
        self.steps: Optional[Sequence[Numeric]]
        warn_js_safe_number(start, stop, value)

        # Guard against conflicting arguments
        if steps is not None and (
            start is not None or stop is not None or step is not None
        ):
            raise ValueError(
                "Invalid arguments: `steps` is mutually exclusive with "
                "`start`, `stop`, and `step`."
            )
        if steps is None and (start is None or stop is None):
            raise ValueError(
                "Missing arguments: `steps` xor both `start`"
                "and `stop` must be provided."
            )
        # If steps are provided
        if steps is not None:
            # Cast to a list in case user passes a numpy array
            if not isinstance(steps, list):
                steps = _convert_numpy_array(steps)
            self._dtype = _infer_dtype(steps)
            self._mapping = dict(enumerate(steps))
            try:
                # check if steps is a sequence of numbers
                assert all(isinstance(num, (int, float)) for num in steps)
                assert len(steps) > 0
                value = steps[0] if value is None else value
                value = steps.index(value)
            except ValueError:
                sys.stderr.write(
                    "Value out of bounds: default value should be in the steps"
                    ", set to first value.\n"
                )
                value = 0
            except AssertionError as e:
                raise TypeError(
                    "Invalid steps: steps must be a sequence of numbers."
                ) from e

            # minimum value of interval
            self.start = steps[0]
            # maximum value of interval
            self.stop = steps[-1]
            # slider increment
            self.step = None
            # list of steps
            self.steps = steps

            super().__init__(
                component_name=slider._name,
                initial_value=value,
                label=label,
                args={
                    "start": 0,
                    "stop": len(steps) - 1,
                    "step": 1,
                    "steps": steps,
                    "debounce": debounce,
                    "orientation": orientation,
                    "show-value": show_value,
                    "full-width": full_width,
                },
                on_change=on_change,
            )
        else:
            assert start is not None
            assert stop is not None

            self._dtype = _infer_dtype([start, stop, step, value])
            value = start if value is None else value

            if stop < start:
                raise ValueError(
                    f"Invalid bounds: stop value ({stop}) "
                    "must be greater than "
                    f"start value ({start})"
                )
            if value < start or value > stop:
                raise ValueError(
                    f"Value out of bounds: default value ({value}) must be "
                    f"greater than start ({start}) "
                    f"and less than stop ({stop})."
                )

            self.start = start
            self.stop = stop
            self.step = step
            self.steps = None

            super().__init__(
                component_name=slider._name,
                initial_value=value,
                label=label,
                args={
                    "start": start,
                    "stop": stop,
                    "step": step if step is not None else None,
                    "steps": [],
                    "debounce": debounce,
                    "orientation": orientation,
                    "show-value": show_value,
                    "full-width": full_width,
                },
                on_change=on_change,
            )

    @staticmethod
    def from_series(series: DataFrameSeries, **kwargs: Any) -> "slider":
        """Create a slider from a dataframe series."""
        info = get_number_series_info(series)
        start = kwargs.pop("start", info.min)
        stop = kwargs.pop("stop", info.max)
        label = kwargs.pop("label", info.label)
        return slider(start=start, stop=stop, label=label, **kwargs)

    def _convert_value(self, value: Numeric) -> Numeric:
        if self._mapping is not None:
            return cast(Numeric, self._dtype(self._mapping[int(value)]))
        return cast(Numeric, self._dtype(value))


@mddoc
class range_slider(UIElement[List[Numeric], Sequence[Numeric]]):
    """
    A numeric slider for specifying a range over an interval.

    **Example.**

    ```python
    range_slider = mo.ui.range_slider(start=1, stop=10, step=2, value=[2, 6])
    ```

    Or from a dataframe series:

    ```python
    range_slider = mo.ui.range_slider.from_series(df["column_name"])
    ```

    Or using numpy arrays:

    ```python
    import numpy as np

    steps = np.array([1, 2, 3, 4, 5])
    # linear steps
    range_slider = mo.ui.range_slider(steps=steps)
    # log steps
    log_range_slider = mo.ui.range_slider(steps=np.logspace(0, 3, 4))
    # power steps
    power_range_slider = mo.ui.range_slider(steps=np.power([1, 2, 3], 2))
    ```

    **Attributes.**

    - `value`: the current range value of the slider
    - `start`: the minimum value of the interval
    - `stop`: the maximum value of the interval
    - `step`: the slider increment
    - `steps`: list of steps

    **Initialization Args.**

    - `start`: the minimum value of the interval
    - `stop`: the maximum value of the interval
    - `step`: the slider increment
    - `value`: default value
    - `debounce`: whether to debounce the slider to only send
        the value on mouse-up or drag-end
    - `orientation`: the orientation of the slider,
        either "horizontal" or "vertical"
    - `show_value`: whether to display the current value of the slider
    - `steps`: list of steps to customize the slider, mutually exclusive
        with `start`, `stop`, and `step`
    - `label`: markdown label for the element
    - `on_change`: optional callback to run when this element's value changes
    - `full_width`: whether the input should take up the full width of its
        container
    """

    _name: Final[str] = "marimo-range-slider"
    _mapping: Optional[dict[int, Numeric]] = None

    def __init__(
        self,
        start: Optional[Numeric] = None,
        stop: Optional[Numeric] = None,
        step: Optional[Numeric] = None,
        value: Optional[Sequence[Numeric]] = None,
        debounce: bool = False,
        orientation: Literal["horizontal", "vertical"] = "horizontal",
        show_value: bool = False,
        steps: Optional[Sequence[Numeric]] = None,
        *,
        label: str = "",
        on_change: Optional[Callable[[Sequence[Numeric]], None]] = None,
        full_width: bool = False,
    ) -> None:
        self.start: Numeric
        self.stop: Numeric
        self.step: Optional[Numeric]
        self.steps: Optional[Sequence[Numeric]]
        warn_js_safe_number(start, stop, *(value or []))

        if steps is not None and (
            start is not None or stop is not None or step is not None
        ):
            raise ValueError(
                "Invalid arguments: `steps` is mutually exclusive with "
                "`start`, `stop`, and `step`."
            )
        if steps is None and (start is None or stop is None):
            raise ValueError(
                "Missing arguments: `steps` xor both `start`"
                "and `stop` must be provided."
            )

        if steps is not None:
            # Cast to a list in case user passes a numpy array
            if not isinstance(steps, list):
                steps = _convert_numpy_array(steps)
            self._dtype = _infer_dtype(steps)
            self._mapping = dict(enumerate(steps))

            try:
                assert all(isinstance(num, (int, float)) for num in steps)
                assert len(steps) > 0
                value = [steps[0], steps[-1]] if value is None else value
                value = [steps.index(num) for num in value]
            except ValueError:
                sys.stderr.write(
                    "Value out of bounds: default value should be in the"
                    "steps, set to first and last values.\n"
                )
                value = [0, len(steps) - 1]
            except AssertionError as e:
                raise TypeError(
                    "Invalid steps: steps must be a sequence of numbers."
                ) from e

            # minimum value of interval
            self.start = steps[0]
            # maximum value of interval
            self.stop = steps[-1]
            # slider increment
            self.step = None
            # list of steps
            self.steps = steps

            super().__init__(
                component_name=range_slider._name,
                initial_value=list(value),
                label=label,
                args={
                    "start": 0,
                    "stop": len(steps) - 1,
                    "step": 1,
                    "steps": steps,
                    "debounce": debounce,
                    "orientation": orientation,
                    "show-value": show_value,
                    "full-width": full_width,
                },
                on_change=on_change,
            )
        else:
            assert start is not None
            assert stop is not None

            self._dtype = _infer_dtype([start, stop, step, value])

            value = [start, stop] if value is None else value

            if stop < start or value[1] < value[0]:
                raise ValueError(
                    "Invalid bounds: stop value must be "
                    "greater than start value."
                )
            if value[0] < start or value[1] > stop:
                raise ValueError(
                    f"Value out of bounds: default value ({value}) must be "
                    f"a range within start ({start}) and stop ({stop})."
                )

            self.start = start
            self.stop = stop
            self.step = step
            self.steps = None

            super().__init__(
                component_name=range_slider._name,
                initial_value=list(value),
                label=label,
                args={
                    "start": start,
                    "stop": stop,
                    "step": step if step is not None else None,
                    "steps": [],
                    "debounce": debounce,
                    "orientation": orientation,
                    "show-value": show_value,
                    "full-width": full_width,
                },
                on_change=on_change,
            )

    @staticmethod
    def from_series(series: DataFrameSeries, **kwargs: Any) -> "range_slider":
        """Create a range slider from a dataframe series."""
        info = get_number_series_info(series)
        start = kwargs.pop("start", info.min)
        stop = kwargs.pop("stop", info.max)
        label = kwargs.pop("label", info.label)
        return range_slider(start=start, stop=stop, label=label, **kwargs)

    def _convert_value(self, value: List[Numeric]) -> Sequence[Numeric]:
        if self._mapping is not None:
            return cast(
                Sequence[Numeric],
                [self._dtype(self._mapping[int(v)]) for v in value],
            )
        return cast(Sequence[Numeric], [self._dtype(v) for v in value])


def _infer_dtype(
    items: Sequence[Union[Numeric, Sequence[Numeric], None]],
) -> type[int] | type[float]:
    """Infer the dtype of a sequence of numbers."""
    for item in items:
        if isinstance(item, Sequence):
            if any(isinstance(subitem, float) for subitem in item):
                return float
        if any(isinstance(item, float) for item in items):
            return float
    return int


@mddoc
class checkbox(UIElement[bool, bool]):
    """A boolean checkbox.

    Examples:
        ```python
        checkbox = mo.ui.checkbox()
        ```

    Attributes:
        value (bool): A boolean, True if checked.

    Args:
        value (bool, optional): Default value, True or False. Defaults to False.
        label (str, optional): Markdown label for the element. Defaults to "".
        on_change (Callable[[bool], None], optional): Optional callback to run when
            this element's value changes. Defaults to None.
    """

    _name: Final[str] = "marimo-checkbox"

    def __init__(
        self,
        value: bool = False,
        *,
        label: str = "",
        on_change: Optional[Callable[[bool], None]] = None,
    ) -> None:
        super().__init__(
            component_name=checkbox._name,
            initial_value=value,
            label=label,
            args={},
            on_change=on_change,
        )

    def _convert_value(self, value: bool) -> bool:
        return value


@mddoc
class radio(UIElement[Optional[str], Any]):
    """A radio group.

    Examples:
        ```python
        radiogroup = mo.ui.radio(
            options=["a", "b", "c"], value="a", label="choose one"
        )
        ```

        ```python
        radiogroup = mo.ui.radio(
            options={"one": 1, "two": 2, "three": 3},
            value="one",
            label="pick a number",
        )
        ```

        Or from a dataframe series:
        ```python
        radiogroup = mo.ui.radio.from_series(df["column_name"])
        ```

    Attributes:
        value (Any): The value of the selected radio option.
        options (dict): A dict mapping option name to option value.

    Args:
        options (Sequence[str] | dict[str, Any]): Sequence of text options, or dict
            mapping option name to option value.
        value (str, optional): Default option name, if None, starts with nothing
            checked. Defaults to None.
        inline (bool, optional): Whether to display options inline. Defaults to False.
        label (str, optional): Optional markdown label for the element. Defaults to "".
        on_change (Callable[[Any], None], optional): Optional callback to run when
            this element's value changes. Defaults to None.
    """

    _name: Final[str] = "marimo-radio"

    def __init__(
        self,
        options: Sequence[str] | dict[str, Any],
        value: Optional[str] = None,
        inline: bool = False,
        *,
        label: str = "",
        on_change: Optional[Callable[[Any], None]] = None,
    ) -> None:
        if not isinstance(options, dict):
            if len(set(options)) != len(options):
                raise ValueError("A radio group cannot have repeated options.")
            options = {option: option for option in options}
        self.options = options
        super().__init__(
            component_name=radio._name,
            initial_value=value,
            label=label,
            args={
                "options": list(options.keys()),
                "inline": inline,
            },
            on_change=on_change,
        )

    @staticmethod
    def from_series(series: DataFrameSeries, **kwargs: Any) -> "radio":
        """Create a radio group from a dataframe series."""
        info = get_category_series_info(series)
        options = kwargs.pop("options", info.categories)
        label = kwargs.pop("label", info.label)
        return radio(options=options, label=label, **kwargs)

    def _convert_value(self, value: Optional[str]) -> Any:
        return self.options[value] if value is not None else None


@mddoc
class text(UIElement[str, str]):
    """A text input.

    Examples:
        ```python
        text = mo.ui.text(value="Hello, World!")
        ```

    Attributes:
        value (str): A string of the input's contents.

    Args:
        value (str, optional): Default value of text box. Defaults to "".
        placeholder (str, optional): Placeholder text to display when the text area
            is empty. Defaults to "".
        kind (Literal["text", "password", "email", "url"], optional): Input kind.
            Defaults to "text".
        max_length (int, optional): Maximum length of input. Defaults to None.
        disabled (bool, optional): Whether the input is disabled. Defaults to False.
        debounce (bool | int, optional): Whether the input is debounced. If number,
            debounce by that many milliseconds. If True, then value is only emitted
            on Enter or when the input loses focus. Defaults to True.
        label (str, optional): Markdown label for the element. Defaults to "".
        on_change (Callable[[str], None], optional): Optional callback to run when
            this element's value changes. Defaults to None.
        full_width (bool, optional): Whether the input should take up the full width
            of its container. Defaults to False.
    """

    _name: Final[str] = "marimo-text"

    def __init__(
        self,
        value: str = "",
        placeholder: str = "",
        kind: Literal["text", "password", "email", "url"] = "text",
        max_length: Optional[int] = None,
        disabled: bool = False,
        debounce: bool | int = True,
        *,
        label: str = "",
        on_change: Optional[Callable[[str], None]] = None,
        full_width: bool = False,
    ) -> None:
        super().__init__(
            component_name=text._name,
            initial_value=value,
            label=label,
            args={
                "placeholder": placeholder,
                "kind": kind,
                "max-length": max_length,
                "full-width": full_width,
                "disabled": disabled,
                "debounce": debounce,
            },
            on_change=on_change,
        )

    def _convert_value(self, value: str) -> str:
        return value


@mddoc
class text_area(UIElement[str, str]):
    """A text area that is larger than `ui.text`.

    Examples:
        ```python
        text_area = mo.ui.text_area()
        ```

    Attributes:
        value (str): A string of the text area contents.

    Args:
        value (str, optional): Initial value of the text area. Defaults to "".
        placeholder (str, optional): Placeholder text to display when the text area
            is empty. Defaults to "".
        max_length (int, optional): Maximum length of input. Defaults to None.
        disabled (bool, optional): Whether the input is disabled. Defaults to False.
        debounce (bool | int, optional): Whether the input is debounced. If number,
            debounce by that many milliseconds. If True, then value is only emitted
            on Ctrl+Enter or when the input loses focus. Defaults to True.
        rows (int, optional): Number of rows of text to display. Defaults to None.
        label (str, optional): Markdown label for the element. Defaults to "".
        on_change (Callable[[str], None], optional): Optional callback to run when
            this element's value changes. Defaults to None.
        full_width (bool, optional): Whether the input should take up the full width
            of its container. Defaults to False.
    """

    _name: Final[str] = "marimo-text-area"

    def __init__(
        self,
        value: str = "",
        placeholder: str = "",
        max_length: Optional[int] = None,
        disabled: bool = False,
        debounce: bool | int = True,
        rows: Optional[int] = None,
        *,
        label: str = "",
        on_change: Optional[Callable[[str], None]] = None,
        full_width: bool = False,
    ) -> None:
        super().__init__(
            component_name=text_area._name,
            initial_value=value,
            label=label,
            args={
                "placeholder": placeholder,
                "max-length": max_length,
                "disabled": disabled,
                "debounce": debounce,
                "full-width": full_width,
                "rows": rows,
            },
            on_change=on_change,
        )

    def _convert_value(self, value: str) -> str:
        return value


@mddoc
class code_editor(UIElement[str, str]):
    """
    A code editor.

    Examples:
        ```python
        code_editor = mo.ui.code_editor()
        ```

    Attributes:
        value (str): A string of the code editor contents.

    Args:
        value (str, optional): Initial value of the code editor. Defaults to "".
        language (str, optional): Language of the code editor. Most major languages
            are supported, including "sql", "javascript", "typescript", "html",
            "css", "c", "cpp", "rust", and more. Defaults to "python".
        placeholder (str, optional): Placeholder text to display when the code editor
            is empty. Defaults to "".
        theme (Literal["light", "dark"], optional): Theme of the code editor.
            Defaults to the editor's default.
        disabled (bool, optional): Whether the input is disabled. Defaults to False.
        min_height (int, optional): Minimum height of the code editor in pixels.
            Defaults to None.
        max_height (int, optional): Maximum height of the code editor in pixels.
            Defaults to None.
        label (str, optional): Markdown label for the element. Defaults to "".
        on_change (Callable[[str], None], optional): Optional callback to run when
            this element's value changes. Defaults to None.
    """

    _name: Final[str] = "marimo-code-editor"

    def __init__(
        self,
        value: str = "",
        language: str = "python",
        placeholder: str = "",
        theme: Optional[Literal["light", "dark"]] = None,
        disabled: bool = False,
        min_height: Optional[int] = None,
        max_height: Optional[int] = None,
        *,
        label: str = "",
        on_change: Optional[Callable[[str], None]] = None,
    ) -> None:
        if (
            min_height is not None
            and max_height is not None
            and min_height > max_height
        ):
            raise ValueError(
                f"min_height ({min_height}) must be <= max_height {max_height}"
            )

        super().__init__(
            component_name=code_editor._name,
            initial_value=value,
            label=label,
            args={
                "language": language,
                "placeholder": placeholder,
                "theme": theme,
                "disabled": disabled,
                "min-height": min_height,
                "max-height": max_height,
            },
            on_change=on_change,
        )

    def _convert_value(self, value: str) -> str:
        return value


@mddoc
class dropdown(UIElement[List[str], Any]):
    """A dropdown selector.

    Examples:
        ```python
        dropdown = mo.ui.dropdown(
            options=["a", "b", "c"], value="a", label="choose one"
        )
        ```

        ```python
        dropdown = mo.ui.dropdown(
            options={"one": 1, "two": 2, "three": 3},
            value="one",
            label="pick a number",
        )
        ```

        Or from a dataframe series:
        ```python
        dropdown = mo.ui.dropdown.from_series(df["column_name"])
        ```

    Attributes:
        value (Any): The selected value, or None if no selection.
        options (dict): A dict mapping option name to option value.
        selected_key (str, optional): The selected option's key, or None if no selection.

    Args:
        options (Sequence[str] | dict[str, Any]): Sequence of text options, or dict
            mapping option name to option value.
        value (str, optional): Default option name. Defaults to None.
        allow_select_none (bool, optional): Whether to include special option ("--")
            for a None value; when None, defaults to True when value is None.
            Defaults to None.
        label (str, optional): Markdown label for the element. Defaults to "".
        on_change (Callable[[Any], None], optional): Optional callback to run when
            this element's value changes. Defaults to None.
        full_width (bool, optional): Whether the input should take up the full width
            of its container. Defaults to False.
    """

    _MAX_OPTIONS: Final[int] = 1000
    _name: Final[str] = "marimo-dropdown"
    _selected_key: Optional[str] = None

    def __init__(
        self,
        options: Sequence[str] | dict[str, Any],
        value: Optional[str] = None,
        allow_select_none: Optional[bool] = None,
        *,
        label: str = "",
        on_change: Optional[Callable[[Any], None]] = None,
        full_width: bool = False,
    ) -> None:
        if len(options) > dropdown._MAX_OPTIONS:
            raise ValueError(
                "The maximum number of dropdown options allowed "
                f"is {dropdown._MAX_OPTIONS}, but your dropdown has "
                f"{len(options)} options. "
                "If you really want to expose that many options, consider "
                "using `mo.ui.text()` to let the user type an option name, "
                "and `mo.ui.table()` to present the options matching the "
                "user's query.",
            )

        if not isinstance(options, dict):
            options = {option: option for option in options}

        if "--" in options:
            raise ValueError(
                "The option name '--' is reserved by marimo"
                "; please use another name."
            )

        self.options = options
        initial_value = [value] if value is not None else []
        if allow_select_none is None:
            allow_select_none = value is None
        elif not allow_select_none and value is None:
            raise ValueError(
                "when `allow_select_none` is False, a non-None default value "
                "must be provided."
            )

        super().__init__(
            component_name=dropdown._name,
            initial_value=initial_value,
            label=label,
            args={
                "options": list(self.options.keys()),
                "allow-select-none": allow_select_none,
                "full-width": full_width,
            },
            on_change=on_change,
        )

    @staticmethod
    def from_series(series: DataFrameSeries, **kwargs: Any) -> "dropdown":
        """Create a dropdown from a dataframe series."""
        info = get_category_series_info(series)
        options = kwargs.pop("options", info.categories)
        label = kwargs.pop("label", info.label)
        return dropdown(options=options, label=label, **kwargs)

    def _convert_value(self, value: list[str]) -> Any:
        if value:
            assert len(value) == 1
            self._selected_key = value[0]
            return self.options[value[0]]
        else:
            self._selected_key = None
            return None

    @property
    def selected_key(self) -> Optional[str]:
        """The selected option's key, or `None` if no selection."""
        return self._selected_key


@mddoc
class multiselect(UIElement[List[str], List[object]]):
    """A multiselect input.

    Examples:
        ```python
        multiselect = mo.ui.multiselect(
            options=["a", "b", "c"], label="choose some options"
        )
        ```

        Or from a dataframe series:
        ```python
        multiselect = mo.ui.multiselect.from_series(df["column_name"])
        ```

    Attributes:
        value (List[object]): The selected values, or None if no selection.
        options (dict): A dict mapping option name to option value.

    Args:
        options (Sequence[str] | dict[str, Any]): Sequence of text options, or dict
            mapping option name to option value.
        value (Sequence[str], optional): A list of initially selected options.
            Defaults to None.
        label (str, optional): Markdown label for the element. Defaults to "".
        on_change (Callable[[List[object]], None], optional): Optional callback to run
            when this element's value changes. Defaults to None.
        full_width (bool, optional): Whether the input should take up the full width
            of its container. Defaults to False.
        max_selections (int, optional): Maximum number of items that can be selected.
            Defaults to None.
    """

    _MAX_OPTIONS: Final[int] = 100000
    _name: Final[str] = "marimo-multiselect"

    def __init__(
        self,
        options: Sequence[str] | dict[str, Any],
        value: Optional[Sequence[str]] = None,
        *,
        label: str = "",
        on_change: Optional[Callable[[List[object]], None]] = None,
        full_width: bool = False,
        max_selections: Optional[int] = None,
    ) -> None:
        if len(options) > multiselect._MAX_OPTIONS:
            raise ValueError(
                "The maximum number of options allowed "
                f"is {multiselect._MAX_OPTIONS}, but your multiselect has "
                f"{len(options)} options. "
                "If you really want to expose that many options, consider "
                "using `mo.ui.text()` to let the user type an option name, "
                "and `mo.ui.table()` to present the options matching the "
                "user's query.",
            )

        if not isinstance(options, dict):
            options = {option: option for option in options}

        self.options = options
        initial_value = list(value) if value is not None else []

        if max_selections is not None:
            if max_selections < 0:
                raise ValueError("max_selections cannot be less than 0.")
            if max_selections < len(initial_value):
                raise ValueError(
                    "Initial value cannot be greater than max_selections."
                )

        super().__init__(
            component_name=multiselect._name,
            initial_value=initial_value,
            label=label,
            args={
                "options": list(self.options.keys()),
                "full-width": full_width,
                "max-selections": max_selections,
            },
            on_change=on_change,
        )

    @staticmethod
    def from_series(series: DataFrameSeries, **kwargs: Any) -> "multiselect":
        """Create a multiselect from a dataframe series."""
        info = get_category_series_info(series)
        options = kwargs.pop("options", info.categories)
        label = kwargs.pop("label", info.label)
        return multiselect(options=options, label=label, **kwargs)

    def _convert_value(self, value: list[str]) -> list[object]:
        return [self.options[v] for v in value]


@mddoc
class button(UIElement[Any, Any]):
    """A button with an optional callback and optional value.

    Examples:
        ```python
        # a button that when clicked will execute
        # any cells referencing that button
        button = mo.ui.button()
        ```

        ```python
        # a counter implementation
        counter_button = mo.ui.button(
            value=0, on_click=lambda value: value + 1, label="increment"
        )

        # adding intent
        delete_button = mo.ui.button(
            label="Do not click",
            kind="danger",
        )
        ```

    Attributes:
        value (Any): The value of the button.

    Args:
        on_click (Callable[[Any], Any], optional): A callable called on click that
            takes the current value of the button and returns a new value.
            Defaults to None.
        value (Any, optional): An initial value for the button. Defaults to None.
        kind (Literal["neutral", "success", "warn", "danger"], optional): Button
            style. Defaults to "neutral".
        disabled (bool, optional): Whether the button is disabled. Defaults to False.
        tooltip (str, optional): Tooltip text for the button. Defaults to None.
        label (str, optional): Markdown label for the element. Defaults to "click here".
        on_change (Callable[[Any], None], optional): Optional callback to run when
            this element's value changes. Defaults to None.
        full_width (bool, optional): Whether the input should take up the full width
            of its container. Defaults to False.
        keyboard_shortcut (str, optional): Keyboard shortcut to trigger the button
            (e.g. 'Ctrl-L'). Defaults to None.
    """

    _name: Final[str] = "marimo-button"

    def __init__(
        self,
        on_click: Optional[Callable[[Any], Any]] = None,
        value: Optional[Any] = None,
        kind: Literal["neutral", "success", "warn", "danger"] = "neutral",
        disabled: bool = False,
        tooltip: Optional[str] = None,
        *,
        label: str = "click here",
        on_change: Optional[Callable[[Any], None]] = None,
        full_width: bool = False,
        keyboard_shortcut: Optional[str] = None,
    ) -> None:
        self._on_click = (lambda _: value) if on_click is None else on_click
        self._initial_value = value
        # This should be kept in sync with mo.ui.run_button()
        super().__init__(
            component_name=button._name,
            # frontend's value is always a counter
            initial_value=0,
            label=label,
            args={
                "kind": kind,
                "disabled": disabled,
                "tooltip": tooltip,
                "full-width": full_width,
                "keyboard-shortcut": keyboard_shortcut,
            },
            on_change=on_change,
        )

    def _convert_value(self, value: Any) -> Any:
        if value == 0:
            # frontend's value == 0 only during initialization; first value
            # frontend will send is 1
            return self._initial_value
        try:
            return self._on_click(self._value)
        except Exception:
            sys.stderr.write(
                "on_click handler for button (%s) raised an Exception:\n %s\n"
                % (str(self), traceback.format_exc())
            )
            return None


@dataclass
class FileUploadResults:
    """A file's name and its contents."""

    name: str
    contents: bytes

    def __repr__(self) -> str:
        # Truncate contents because it can be very long
        return f"FileUploadResults(name='{self.name}', contents=...)"


@mddoc
class file(UIElement[List[Tuple[str, str]], Sequence[FileUploadResults]]):
    """A button or drag-and-drop area to upload a file.

    Once a file is uploaded, the UI element's value is a list of namedtuples
    (name, contents), where name is the filename and contents is the contents
    of the file. Alternatively, use the methods name(index: int = 0) and
    contents(index: int = 0) to retrieve the name or contents of the file at a
    specified index.

    Use the kind argument to switch between a button and a drag-and-drop area.

    The maximum file size is 100MB.

    Examples:
        Uploading a single file:
        ```python
        f = mo.ui.file()

        # access the uploaded file's name
        f.value[0].name
        # or
        f.name()

        # access the uploaded file's contents
        f.value[0].contents
        # or
        f.contents()
        ```

        Uploading multiple files, accepting only .png and .jpg extensions:
        ```python
        f = mo.ui.file(filetypes=[".png", ".jpg"], multiple=True)

        # access an uploaded file's name
        f.value[index].name
        # or
        f.name(index)

        # access the uploaded file's contents
        f.value[index].contents
        # or
        f.contents(index)
        ```

    Attributes:
        value (Sequence[FileUploadResults]): A sequence of FileUploadResults,
            which have string name and bytes contents fields.

    Methods:
        name(index: int = 0) -> Optional[str]: Get the name of the uploaded file
            at index.
        contents(index: int = 0) -> Optional[bytes]: Get the contents of the
            uploaded file at index.

    Args:
        filetypes (Sequence[str], optional): The file types accepted; for example,
            filetypes=[".png", ".jpg"]. If None, all files are accepted.
            In addition to extensions, you may provide "audio/*", "video/*",
            or "image/*" to accept any audio, video, or image file.
            Defaults to None.
        multiple (bool, optional): If True, allow the user to upload multiple
            files. Defaults to False.
        kind (Literal["button", "area"], optional): Type of upload interface.
            Defaults to "button".
        label (str, optional): Markdown label for the element. Defaults to "".
        on_change (Callable[[Sequence[FileUploadResults]], None], optional):
            Optional callback to run when this element's value changes.
            Defaults to None.
    """

    _name: Final[str] = "marimo-file"

    def __init__(
        self,
        filetypes: Optional[Sequence[str]] = None,
        multiple: bool = False,
        kind: Literal["button", "area"] = "button",
        *,
        label: str = "",
        on_change: Optional[
            Callable[[Sequence[FileUploadResults]], None]
        ] = None,
    ) -> None:
        # Validate filetypes have leading dots or contain a forward slash
        if filetypes is not None:
            invalid_types = [
                ft for ft in filetypes if not (ft.startswith(".") or "/" in ft)
            ]
            if invalid_types:
                raise ValueError(
                    f"File types must start with a dot (e.g., '.csv' instead of 'csv') "
                    f"or contain a forward slash (e.g., 'application/json'). "
                    f"Invalid types: {', '.join(invalid_types)}"
                )

        super().__init__(
            component_name=file._name,
            initial_value=[],
            label=label,
            args={
                "filetypes": filetypes if filetypes is not None else [],
                "multiple": multiple,
                "kind": kind,
            },
            on_change=on_change,
        )

    def _convert_value(
        self, value: list[tuple[str, str]]
    ) -> Sequence[FileUploadResults]:
        return tuple(
            FileUploadResults(name=e[0], contents=base64.b64decode(e[1]))
            for e in value
        )

    def name(self, index: int = 0) -> Optional[str]:
        """Get file name at index.

        Args:
            index (int, optional): Index of the file to get the name from.
                Defaults to 0.

        Returns:
            Optional[str]: The name of the file at the specified index,
                or None if index is out of range.
        """
        if not self.value or index >= len(self.value):
            return None
        else:
            return self.value[index].name

    def contents(self, index: int = 0) -> Optional[bytes]:
        """Get file contents at index.

        Args:
            index (int, optional): Index of the file to get the contents from.
                Defaults to 0.

        Returns:
            Optional[bytes]: The contents of the file at the specified index,
                or None if index is out of range.
        """
        if not self.value or index >= len(self.value):
            return None
        else:
            return self.value[index].contents


@dataclass
class ListDirectoryArgs:
    path: str


@dataclass
class ListDirectoryResponse:
    files: List[FileInfo]


@mddoc
class file_browser(UIElement[List[Dict[str, Any]], Sequence[FileInfo]]):
    """File browser for browsing and selecting server-side files.

    Examples:
        Selecting multiple files:
        ```python
        file_browser = mo.ui.file_browser(
            initial_path="path/to/dir", multiple=True
        )

        # Access the selected file path(s):
        file_browser.path(index)

        # Get name of selected file(s)
        file_browser.name(index)
        ```

    Attributes:
        value (Sequence[FileInfo]): A sequence of file paths representing selected
            files.

    Args:
        initial_path (str, optional): Starting directory. Defaults to current
            working directory.
        filetypes (Sequence[str], optional): The file types to display in each
            directory; for example, filetypes=[".txt", ".csv"]. If None, all
            files are displayed. Defaults to None.
        selection_mode (str, optional): Either "file" or "directory". Defaults to
            "file".
        multiple (bool, optional): If True, allow the user to select multiple
            files. Defaults to True.
        restrict_navigation (bool, optional): If True, prevent the user from
            navigating any level above the given path. Defaults to False.
        label (str, optional): Markdown label for the element. Defaults to "".
        on_change (Callable[[Sequence[FileInfo]], None], optional): Optional
            callback to run when this element's value changes. Defaults to None.
    """

    _name: Final[str] = "marimo-file-browser"

    def __init__(
        self,
        initial_path: str = "",
        filetypes: Optional[Sequence[str]] = None,
        selection_mode: str = "file",
        multiple: bool = True,
        restrict_navigation: bool = False,
        *,
        label: str = "",
        on_change: Optional[Callable[[Sequence[FileInfo]], None]] = None,
    ) -> None:
        self.filetypes = filetypes

        if (
            selection_mode != "file"
            and selection_mode != "directory"
            and selection_mode != "all"
        ):
            raise ValueError(
                "Invalid argument for selection_mode. "
                + "Must be either 'file' or 'directory'."
            )
        else:
            self.selection_mode = selection_mode

        if not initial_path:
            initial_path = os.getcwd()

        # frontend plugin can't handle relative paths
        initial_path = os.path.realpath(os.path.expanduser(initial_path))
        # initial path must be a directory
        if not os.path.isdir(initial_path):
            raise ValueError(
                f"Initial path {initial_path} is not a directory."
            )

        self.restrict_navigation = restrict_navigation
        self.initial_path = initial_path
        super().__init__(
            component_name=file_browser._name,
            initial_value=[],
            label=label,
            args={
                "initial-path": initial_path,
                "selection-mode": selection_mode,
                "filetypes": filetypes if filetypes is not None else [],
                "multiple": multiple,
                "restrict-navigation": restrict_navigation,
            },
            functions=(
                Function(
                    name="list_directory",
                    arg_cls=ListDirectoryArgs,
                    function=self._list_directory,
                ),
            ),
            on_change=on_change,
        )

    def _list_directory(
        self, args: ListDirectoryArgs
    ) -> ListDirectoryResponse:
        # When navigation is restricted, the navigated-to path cannot be
        # be a parent of the initial path
        if (
            self.restrict_navigation
            and pathlib.Path(args.path)
            in pathlib.Path(self.initial_path).parents
        ):
            raise RuntimeError(
                "Navigation is restricted; navigating to a "
                "parent of initial path is not allowed."
            )

        files = []
        files_in_path = OSFileSystem().list_files(args.path)

        for file in files_in_path:
            _, extension = os.path.splitext(file.name)

            if self.selection_mode == "directory" and not file.is_directory:
                continue

            if self.filetypes and not file.is_directory:
                if extension not in self.filetypes:
                    continue

            files.append(file)

        return ListDirectoryResponse(files)

    def _convert_value(
        self, value: list[Dict[str, Any]]
    ) -> Sequence[FileInfo]:
        return tuple(
            FileInfo(
                id=file["id"],
                name=file["name"],
                path=file["path"],
                is_directory=file["is_directory"],
                is_marimo_file=file["is_marimo_file"],
            )
            for file in value
        )

    def name(self, index: int = 0) -> Optional[str]:
        """Get file name at index.

        Args:
            index (int, optional): Index of the file to get the name from.
                Defaults to 0.

        Returns:
            Optional[str]: The name of the file at the specified index,
                or None if index is out of range.
        """
        if not self.value or index >= len(self.value):
            return None
        else:
            return self.value[index].name

    def path(self, index: int = 0) -> Optional[str]:
        """Get file path at index.

        Args:
            index (int, optional): Index of the file to get the path from.
                Defaults to 0.

        Returns:
            Optional[str]: The path of the file at the specified index,
                or None if index is out of range.
        """
        if not self.value or index >= len(self.value):
            return None
        else:
            return self.value[index].path


T = TypeVar("T")


@dataclasses.dataclass
class ValueArgs:
    value: Optional[JSONType] = None


@mddoc
class form(UIElement[Optional[JSONTypeBound], Optional[T]]):
    """A submittable form linked to a UIElement.

    Use a `form` to prevent sending UI element values to Python until a button
    is clicked.

    The value of a `form` is the value of the underlying element the last time
    the form was submitted.

    Examples:
        Create a form with chaining:
        ```python
        # Create a form with chaining
        form = mo.ui.slider(1, 100).form()
        ```

        Create a form with multiple elements:
        ```python
        # Create a form with multiple elements
        form = (
            mo.md('''
            **Your form.**

            {name}

            {date}
        ''')
            .batch(
                name=mo.ui.text(label="name"),
                date=mo.ui.date(label="date"),
            )
            .form(show_clear_button=True, bordered=False)
        )
        ```

        Instantiate a form directly:
        ```python
        # Instantiate a form directly
        form = mo.ui.form(element=mo.ui.slider(1, 100))
        ```

    Attributes:
        value (Any): The value of the wrapped element when the form's submit
            button was last clicked.
        element (UIElement): A copy of the wrapped element.

    Args:
        element (UIElement[JSONTypeBound, T]): The element to wrap.
        bordered (bool, optional): Whether the form should have a border.
            Defaults to True.
        loading (bool, optional): Whether the form should be in a loading state.
            Defaults to False.
        submit_button_label (str, optional): The label of the submit button.
            Defaults to "Submit".
        submit_button_tooltip (str, optional): The tooltip of the submit button.
            Defaults to None.
        submit_button_disabled (bool, optional): Whether the submit button should
            be disabled. Defaults to False.
        clear_on_submit (bool, optional): Whether the form should clear its
            contents after submitting. Defaults to False.
        show_clear_button (bool, optional): Whether the form should show a clear
            button. Defaults to False.
        clear_button_label (str, optional): The label of the clear button.
            Defaults to "Clear".
        clear_button_tooltip (str, optional): The tooltip of the clear button.
            Defaults to None.
        validate (Callable[[Optional[JSONType]], Optional[str]], optional): A
            function that takes the form's value and returns an error message if
            the value is invalid, or None if the value is valid. Defaults to None.
        label (str, optional): Markdown label for the form. Defaults to "".
        on_change (Callable[[Optional[T]], None], optional): Optional callback to
            run when this element's value changes. Defaults to None.
    """

    _name: Final[str] = "marimo-form"

    def __init__(
        self,
        element: UIElement[JSONTypeBound, T],
        *,
        bordered: bool = True,
        loading: bool = False,
        submit_button_label: str = "Submit",
        submit_button_tooltip: Optional[str] = None,
        submit_button_disabled: bool = False,
        clear_on_submit: bool = False,
        show_clear_button: bool = False,
        clear_button_label: str = "Clear",
        clear_button_tooltip: Optional[str] = None,
        validate: Optional[
            Callable[[Optional[JSONType]], Optional[str]]
        ] = None,
        label: str = "",
        on_change: Optional[Callable[[Optional[T]], None]] = None,
    ) -> None:
        self.element = element._clone()
        self.validate = validate
        super().__init__(
            component_name=form._name,
            initial_value=None,
            label=label,
            args={
                "element-id": self.element._id,
                "loading": loading,
                "bordered": bordered,
                "submit-button-label": submit_button_label,
                "submit-button-tooltip": submit_button_tooltip,
                "submit-button-disabled": submit_button_disabled,
                "clear-on-submit": clear_on_submit,
                "show-clear-button": show_clear_button,
                "clear-button-label": clear_button_label,
                "clear-button-tooltip": clear_button_tooltip,
                "should-validate": validate is not None,
            },
            slotted_html=self.element.text,
            on_change=on_change,
            functions=(
                Function(
                    name="validate",
                    arg_cls=ValueArgs,
                    function=self._validate,
                ),
            ),
        )

    def _validate(self, value: ValueArgs) -> Optional[str]:
        if self.validate is None:
            return None
        return self.validate(value.value)

    def _convert_value(self, value: Optional[JSONTypeBound]) -> Optional[T]:
        if value is None:
            return None
        self.element._update(value)
        return self.element.value


def _convert_numpy_array(steps: Any) -> list[Numeric]:
    """Convert numpy array to list if needed."""
    if DependencyManager.numpy.imported():
        import numpy as np

        if isinstance(steps, np.ndarray):
            return steps.tolist()  # type: ignore[return-value,no-any-return]
    return steps  # type: ignore[no-any-return]
