# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025)
#
# 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 typing import Any, Callable, Final, TypeVar, cast

import streamlit
from streamlit import config
from streamlit.logger import get_logger

_LOGGER: Final = get_logger(__name__)

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


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


def show_deprecation_warning(message: str, show_in_browser: bool = True) -> 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 unless the user has
        disabled error details in the browser by setting the `client.showErrorDetails`
        config option to "none".
    """
    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,
) -> 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__.
    """

    @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,
            )
        )
        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())
