# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import functools
from collections.abc import Callable
from typing import Any, Final, TypeVar, cast

import streamlit
from streamlit import config
from streamlit.logger import get_logger
from streamlit.util import calc_hash

_LOGGER: Final = get_logger(__name__)

TFunc = TypeVar("TFunc", bound=Callable[..., Any])
TObj = TypeVar("TObj", bound=object)

# Set to track which deprecation warnings have been shown (by message hash)
# when show_once=True is used
_shown_warnings: set[str] = set()


def _error_details_in_browser_enabled() -> bool:
    """True if we should print deprecation warnings to the browser.

    Deprecation warnings are only shown when showErrorDetails is set to "full"
    or the legacy True value. All other values ("stacktrace", "type", "none",
    False) hide deprecation warnings in the browser.
    """
    show_error_details = config.get_option("client.showErrorDetails")
    return (
        show_error_details == config.ShowErrorDetailsConfigOptions.FULL
        or config.ShowErrorDetailsConfigOptions.is_true_variation(show_error_details)
    )


def show_deprecation_warning(
    message: str, show_in_browser: bool = True, show_once: bool = False
) -> None:
    """Show a deprecation warning message.

    Parameters
    ----------
    message : str
        The deprecation warning message.
    show_in_browser : bool, default=True
        Whether to show the deprecation warning in the browser. When this is True,
        we will show the deprecation warning in the browser only if the user has
        set `client.showErrorDetails` to "full" or the legacy True value. All
        other values ("stacktrace", "type", "none", False) will hide deprecation
        warnings in the browser (but still log them to the console).
    show_once : bool, default=False
        If True, the warning will only be shown once per unique message (based on
        message hash). Subsequent calls with the same message will be skipped.
        This is useful for warnings that may be triggered many times during a
        script run.
    """
    if show_once:
        message_hash = calc_hash(message)
        if message_hash in _shown_warnings:
            return
        _shown_warnings.add(message_hash)

    if _error_details_in_browser_enabled() and show_in_browser:
        streamlit.warning(message)

    # We always log deprecation warnings
    _LOGGER.warning(message)


def make_deprecated_name_warning(
    old_name: str,
    new_name: str,
    removal_date: str,
    extra_message: str | None = None,
    include_st_prefix: bool = True,
) -> str:
    if include_st_prefix:
        old_name = f"st.{old_name}"
        new_name = f"st.{new_name}"

    return (
        f"Please replace `{old_name}` with `{new_name}`.\n\n"
        f"`{old_name}` will be removed after {removal_date}."
        + (f"\n\n{extra_message}" if extra_message else "")
    )


def deprecate_func_name(
    func: TFunc,
    old_name: str,
    removal_date: str,
    extra_message: str | None = None,
    name_override: str | None = None,
    include_st_prefix: bool = True,
    show_in_browser: bool = True,
    show_once: bool = False,
) -> TFunc:
    """Wrap an `st` function whose name has changed.

    Wrapped functions will run as normal, but will also show an st.warning
    saying that the old name will be removed after removal_date.

    (We generally set `removal_date` to 3 months from the deprecation date.)

    Parameters
    ----------
    func
        The `st.` function whose name has changed.

    old_name
        The function's deprecated name within __init__.py.

    removal_date
        A date like "2020-01-01", indicating the last day we'll guarantee
        support for the deprecated name.

    extra_message
        An optional extra message to show in the deprecation warning.

    name_override
        An optional name to use in place of func.__name__.

    include_st_prefix
        If False, does not prefix each of the function names in the deprecation
        message with `st.*`. Defaults to True.

    show_in_browser
        Whether to show the deprecation warning in the browser. Defaults to True.
        Set to False for less intrusive deprecation warnings that only log to
        the console.

    show_once
        If True, the warning will only be shown once per unique message.
        Defaults to False.
    """

    @functools.wraps(func)
    def wrapped_func(*args: Any, **kwargs: Any) -> Any:
        result = func(*args, **kwargs)
        show_deprecation_warning(
            make_deprecated_name_warning(
                old_name,
                name_override
                or (str(func.__name__) if hasattr(func, "__name__") else "unknown"),
                removal_date,
                extra_message,
                include_st_prefix=include_st_prefix,
            ),
            show_in_browser=show_in_browser,
            show_once=show_once,
        )
        return result

    # Update the wrapped func's name & docstring so st.help does the right thing
    wrapped_func.__name__ = old_name
    wrapped_func.__doc__ = func.__doc__
    return cast("TFunc", wrapped_func)


def deprecate_obj_name(
    obj: TObj,
    old_name: str,
    new_name: str,
    removal_date: str,
    include_st_prefix: bool = True,
) -> TObj:
    """Wrap an `st` object whose name has changed.

    Wrapped objects will behave as normal, but will also show an st.warning
    saying that the old name will be removed after `removal_date`.

    (We generally set `removal_date` to 3 months from the deprecation date.)

    Parameters
    ----------
    obj
        The `st.` object whose name has changed.

    old_name
        The object's deprecated name within __init__.py.

    new_name
        The object's new name within __init__.py.

    removal_date
        A date like "2020-01-01", indicating the last day we'll guarantee
        support for the deprecated name.

    include_st_prefix
        If False, does not prefix each of the object names in the deprecation
        message with `st.*`. Defaults to True.
    """

    return _create_deprecated_obj_wrapper(
        obj,
        lambda: show_deprecation_warning(
            make_deprecated_name_warning(
                old_name, new_name, removal_date, include_st_prefix=include_st_prefix
            )
        ),
    )


def _create_deprecated_obj_wrapper(obj: TObj, show_warning: Callable[[], Any]) -> TObj:
    """Create a wrapper for an object that has been deprecated. The first
    time one of the object's properties or functions is accessed, the
    given `show_warning` callback will be called.
    """
    has_shown_warning = False

    def maybe_show_warning() -> None:
        # Call `show_warning` if it hasn't already been called once.
        nonlocal has_shown_warning
        if not has_shown_warning:
            has_shown_warning = True
            show_warning()

    class Wrapper:
        def __init__(self) -> None:
            # Override all the Wrapped object's magic functions
            for name in Wrapper._get_magic_functions(obj.__class__):
                setattr(
                    self.__class__,
                    name,
                    property(self._make_magic_function_proxy(name)),
                )

        def __getattr__(self, attr: str) -> Any:
            # We handle __getattr__ separately from our other magic
            # functions. The wrapped class may not actually implement it,
            # but we still need to implement it to call all its normal
            # functions.
            if attr in self.__dict__:
                return getattr(self, attr)

            maybe_show_warning()
            return getattr(obj, attr)

        @staticmethod
        def _get_magic_functions(self_cls: type[object]) -> list[str]:
            # ignore the handful of magic functions we cannot override without
            # breaking the Wrapper.
            ignore = ("__class__", "__dict__", "__getattribute__", "__getattr__")
            return [
                name
                for name in dir(self_cls)
                if name not in ignore and name.startswith("__")
            ]

        @staticmethod
        def _make_magic_function_proxy(name: str) -> Callable[[Any], Any]:
            def proxy(_self: Any, *args: Any) -> Any:
                maybe_show_warning()
                return getattr(obj, name)

            return proxy

    return cast("TObj", Wrapper())
