from __future__ import annotations

import functools
import inspect
from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast
from warnings import catch_warnings, simplefilter
from warnings import warn as _warn

if TYPE_CHECKING:  # pragma: no cover
    from collections.abc import Callable, Sequence
    from typing import Any, Literal


def deprecated(
    _type: Literal["class", "method", "function", "argument", "module"],
    old: str,
    new: str,
    version: str,
    reason: str | None = None,
    stack_level: int = 4,
) -> None:
    """a convenience function for deprecating classes, functions, arguments.

    Parameters
    ----------
    _type
        should be one of class, method, function, argument, module
    old, new
        the old and new names
    version
        the version by which support for the old name will be
        discontinued
    reason
        why, and what choices users have
    stack_level
        as per warnings.warn

    """
    msg = f"{_type} {old} will be removed in version {version}, use {new} instead"
    if reason is not None:
        msg = f"{msg}\nreason={reason!r}"

    with catch_warnings():
        simplefilter("always")
        _warn(msg, DeprecationWarning, stacklevel=stack_level)


def discontinued(
    _type: Literal["class", "method", "function", "argument", "module"],
    old: str,
    version: str,
    reason: str | None = None,
    stack_level: int = 4,
) -> None:
    """convenience func to warn about discontinued attributes

    Parameters
    ----------
    _type
        should be one of class, method, function, argument, module
    old
        the attributes name
    version
        the version by which support for the old name will be
        discontinued
    reason
        why, and what choices users have
    stack_level
        as per warnings.warn
    """
    msg = f"{_type} {old} is discontinued and will be removed in version {version}"
    if reason is not None:
        msg = f"{msg}\nreason={reason!r}"

    with catch_warnings():
        simplefilter("always")
        _warn(msg, DeprecationWarning, stacklevel=stack_level)


_discontinued = discontinued  # renamed to avoid name clash with discontinued argument in deprecated args decorator

P = ParamSpec("P")
R = TypeVar("R")


def deprecated_args(
    *,
    version: str,
    reason: str,
    old_new: Sequence[tuple[str, str]] | None = None,
    discontinued: Sequence[str] | None = None,
    stack_level: int = 4,
) -> Callable[..., Any]:
    """
    A decorator that marks specific arguments of a function as deprecated.

    The decorator accepts a list of 2-tuples specifying the mapping of old
    argument names to new argument names. When the decorated function is
    called with any of the old argument names, they will be replaced with their
    corresponding new names in the kwargs dictionary.

    Parameters
    ----------
    version
        The version when the old arguments will be removed in calver
        format, e.g. 'YYYY.MM'
    reason
        Reason for deprecation or guidance on what to do
    old-new
        A list of deprecated old and replacement new argument names.
    discontinued
        Names of single or multiple arguments to be discontinued. This should
        only be applied to arguments that have no effect.

    Returns
    -------
    The decorated function.

    Warnings
    --------
    DeprecationWarning
        A warning will be raised when the decorated function is called for
        each deprecated argument used in the calling function.

    Examples
    --------
    To use, change the signature of the function / method by removing the
    deprecated / discontinued arguments. Apply the decorator to the function,
    indicating the old and new the argument names.

    >>> @deprecated_args(
    ...     version="2024.1",
    ...     reason="Use new_name instead",
    ...     old_new=[("old_arg", "new_arg")],
    ... )
    ... def my_function(new_arg):
    ...     return new_arg
    >>> my_function(new_arg=42)
    42
    """

    if old_new is None:
        old_new = []

    discontinued = [discontinued] if isinstance(discontinued, str) else discontinued
    old_args = dict(old_new).keys()

    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            if old_args & kwargs.keys():
                for old, new in old_new:
                    if old in kwargs:
                        kwargs[new] = kwargs.pop(old)
                        deprecated(
                            "argument",
                            old,
                            new,
                            version,
                            reason,
                            stack_level=stack_level,
                        )
            if discontinued:
                for dropped in discontinued:
                    if dropped in kwargs:
                        _discontinued(
                            "argument",
                            dropped,
                            version,
                            reason,
                            stack_level=stack_level,
                        )
                        kwargs.pop(dropped)

            return func(*args, **kwargs)

        return wrapper

    return decorator


def deprecated_callable(
    *,
    version: str,
    reason: str,
    new: str | None = None,
    is_discontinued: bool = False,
    stack_level: int = 4,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
    """
    A decorator that marks callables (function or method) as deprecated or discontinued..
    Parameters
    ----------
    version
        The version when it will be removed in calver format, e.g. 'YYYY.MM'
    reason
        Reason for deprecation or guidance on what to do
    new
        If the callable is being replaced, this is the replacement, e.g. 'ClassName.new_method()'
    is_discontinued
        If True the callable is being discontinued.
    stack_level
        as per warnings.warn

    Returns
    -------
    The decorated callable.

    Warnings
    --------
    DeprecationWarning
        A warning will be raised when the decorated function is called.

    Examples
    --------
    Here's an example of how to use the `deprecated_callable` decorator to mark the function `my_function` as deprecated
    in favour of a new function.

    >>> @deprecated_callable(
    ...     version="2023.6", reason="function rename", new="a_function"
    ... )
    ... def my_function(arg):
    ...     return arg
    >>> callable(my_function)
    True

    """

    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        sig = set(inspect.signature(func).parameters)
        _type: Literal["method", "function", "class"] = (
            "method" if sig & {"self", "cls", "klass"} else "function"
        )
        old = func.__name__
        if old in {"__init__", "__init_subclass__"}:
            # we're really deprecating a class, so get that name
            old = func.__qualname__.split(".")[-2]
            _type = "class"

        if not is_discontinued and new is None:
            msg = "Must specify new callable if deprecating."
            raise ValueError(msg)

        def depr_func() -> None:
            if is_discontinued:
                discontinued(_type, old, version, reason, stack_level)
            else:
                deprecated(_type, old, cast("str", new), version, reason, stack_level)

        @functools.wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            depr_func()
            return func(*args, **kwargs)

        return wrapper

    return decorator
