"""Function for recording and reporting deprecations.

Note
-----
this file is copied (with minor modifications) from the Nibabel.
https://github.com/nipy/nibabel. See COPYING file distributed along with
the Nibabel package for the copyright and license terms.

"""

import functools
from inspect import signature
import re
import warnings

from packaging.version import parse as version_cmp

from dipy import __version__
from dipy.testing.decorators import warning_for_keywords

_LEADING_WHITE = re.compile(r"^(\s*)")


class ExpiredDeprecationError(RuntimeError):
    """Error for expired deprecation.

    Error raised when a called function or method has passed out of its
    deprecation period.

    """

    pass


class ArgsDeprecationWarning(DeprecationWarning):
    """Warning for args deprecation.

    Warning raised when a function or method argument has changed or removed.

    """

    pass


def _ensure_cr(text):
    """Remove trailing whitespace and add carriage return.

    Ensures that `text` always ends with a carriage return
    """
    return text.rstrip() + "\n"


def _add_dep_doc(old_doc, dep_doc):
    """Add deprecation message `dep_doc` to docstring in `old_doc`.

    Parameters
    ----------
    old_doc : str
        Docstring from some object.
    dep_doc : str
        Deprecation warning to add to top of docstring, after initial line.

    Returns
    -------
    new_doc : str
        `old_doc` with `dep_doc` inserted after any first lines of docstring.

    """
    dep_doc = _ensure_cr(dep_doc)
    if not old_doc:
        return dep_doc
    old_doc = _ensure_cr(old_doc)
    old_lines = old_doc.splitlines()
    new_lines = []
    for _line_no, line in enumerate(old_lines):
        if line.strip():
            new_lines.append(line)
        else:
            break
    next_line = _line_no + 1
    if next_line >= len(old_lines):
        # nothing following first paragraph, just append message
        return f"{old_doc}\n{dep_doc}"
    indent = _LEADING_WHITE.match(old_lines[next_line]).group()
    dep_lines = [indent + L for L in [""] + dep_doc.splitlines() + [""]]
    return "\n".join(new_lines + dep_lines + old_lines[next_line:]) + "\n"


@warning_for_keywords()
def cmp_pkg_version(version_str, *, pkg_version_str=__version__):
    """Compare `version_str` to current package version.

    Parameters
    ----------
    version_str : str
        Version string to compare to current package version
    pkg_version_str : str, optional
        Version of our package.  Optional, set from ``__version__`` by default.

    Returns
    -------
    version_cmp : int
        1 if `version_str` is a later version than `pkg_version_str`, 0 if
        same, -1 if earlier.

    Examples
    --------
    >>> cmp_pkg_version('1.2.1', pkg_version_str='1.2.0')
    1
    >>> cmp_pkg_version('1.2.0dev', pkg_version_str='1.2.0')
    -1

    """
    if any(re.match(r"^[a-z, A-Z]", v) for v in [version_str, pkg_version_str]):
        msg = f"Invalid version {version_str} or {pkg_version_str}"
        raise ValueError(msg)
    elif version_cmp(version_str) > version_cmp(pkg_version_str):
        return 1
    elif version_cmp(version_str) == version_cmp(pkg_version_str):
        return 0
    else:
        return -1


@warning_for_keywords()
def is_bad_version(version_str, *, version_comparator=cmp_pkg_version):
    """Return True if `version_str` is too high."""
    return version_comparator(version_str) == -1


@warning_for_keywords()
def deprecate_with_version(
    message,
    *,
    since="",
    until="",
    version_comparator=cmp_pkg_version,
    warn_class=DeprecationWarning,
    error_class=ExpiredDeprecationError,
):
    """Return decorator function function for deprecation warning / error.

    The decorated function / method will:

    * Raise the given `warning_class` warning when the function / method gets
      called, up to (and including) version `until` (if specified);
    * Raise the given `error_class` error when the function / method gets
      called, when the package version is greater than version `until` (if
      specified).

    Parameters
    ----------
    message : str
        Message explaining deprecation, giving possible alternatives.
    since : str, optional
        Released version at which object was first deprecated.
    until : str, optional
        Last released version at which this function will still raise a
        deprecation warning.  Versions higher than this will raise an
        error.
    version_comparator : callable
        Callable accepting string as argument, and return 1 if string
        represents a higher version than encoded in the `version_comparator`, 0
        if the version is equal, and -1 if the version is lower.  For example,
        the `version_comparator` may compare the input version string to the
        current package version string.
    warn_class : class, optional
        Class of warning to generate for deprecation.
    error_class : class, optional
        Class of error to generate when `version_comparator` returns 1 for a
        given argument of ``until``.

    Returns
    -------
    deprecator : func
        Function returning a decorator.

    """
    messages = [message]
    if (since, until) != ("", ""):
        messages.append("")
    if since:
        messages.append(f"* deprecated from version: {since}")
    if until:
        raise_will_raise = "Raises" if is_bad_version(until) else "Will raise"
        messages.append(f"* {raise_will_raise} {error_class} as of version: {until}")
    message = "\n".join(messages)

    def deprecator(func):
        @functools.wraps(func)
        def deprecated_func(*args, **kwargs):
            if until and is_bad_version(until, version_comparator=version_comparator):
                raise error_class(message)
            warnings.warn(message, warn_class, stacklevel=2)
            return func(*args, **kwargs)

        deprecated_func.__doc__ = _add_dep_doc(deprecated_func.__doc__, message)
        return deprecated_func

    return deprecator


@warning_for_keywords()
def deprecated_params(
    old_name,
    *,
    new_name=None,
    since="",
    until="",
    version_comparator=cmp_pkg_version,
    arg_in_kwargs=False,
    warn_class=ArgsDeprecationWarning,
    error_class=ExpiredDeprecationError,
    alternative="",
):
    """Deprecate a *renamed* or *removed* function argument.

    The decorator assumes that the argument with the ``old_name`` was removed
    from the function signature and the ``new_name`` replaced it at the
    **same position** in the signature.  If the ``old_name`` argument is
    given when calling the decorated function the decorator will catch it and
    issue a deprecation warning and pass it on as ``new_name`` argument.

    Parameters
    ----------
    old_name : str or list/tuple thereof
        The old name of the argument.
    new_name : str or list/tuple thereof or ``None``, optional
        The new name of the argument. Set this to `None` to remove the
        argument ``old_name`` instead of renaming it.
    since : str or number or list/tuple thereof, optional
        The release at which the old argument became deprecated.
    until : str or number or list/tuple thereof, optional
        Last released version at which this function will still raise a
        deprecation warning.  Versions higher than this will raise an
        error.
    version_comparator : callable
        Callable accepting string as argument, and return 1 if string
        represents a higher version than encoded in the ``version_comparator``,
        0 if the version is equal, and -1 if the version is lower. For example,
        the ``version_comparator`` may compare the input version string to the
        current package version string.
    arg_in_kwargs : bool or list/tuple thereof, optional
        If the argument is not a named argument (for example it
        was meant to be consumed by ``**kwargs``) set this to
        ``True``.  Otherwise the decorator will throw an Exception
        if the ``new_name`` cannot be found in the signature of
        the decorated function.
        Default is ``False``.
    warn_class : warning, optional
        Warning to be issued.
    error_class : Exception, optional
        Error to be issued
    alternative : str, optional
        An alternative function or class name that the user may use in
        place of the deprecated object if ``new_name`` is None. The deprecation
        warning will tell the user about this alternative if provided.

    Raises
    ------
    TypeError
        If the new argument name cannot be found in the function
        signature and arg_in_kwargs was False or if it is used to
        deprecate the name of the ``*args``-, ``**kwargs``-like arguments.
        At runtime such an Error is raised if both the new_name
        and old_name were specified when calling the function and
        "relax=False".

    Notes
    -----
    This function is based on the Astropy (major modification).
    https://github.com/astropy/astropy. See COPYING file distributed along with
    the astropy package for the copyright and license terms.

    Examples
    --------
    The deprecation warnings are not shown in the following examples.
    To deprecate a positional or keyword argument::
    >>> from dipy.utils.deprecator import deprecated_params
    >>> @deprecated_params('sig', new_name='sigma', since='0.3')
    ... def test(sigma):
    ...     return sigma
    >>> test(2)
    2
    >>> test(sigma=2)
    2
    >>> test(sig=2)  # doctest: +SKIP
    2

    It is also possible to replace multiple arguments. The ``old_name``,
    ``new_name`` and ``since`` have to be `tuple` or `list` and contain the
    same number of entries::
    >>> @deprecated_params(['a', 'b'], new_name=['alpha', 'beta'],
    ...                    since=['0.2', 0.4])
    ... def test(alpha, beta):
    ...     return alpha, beta
    >>> test(a=2, b=3)  # doctest: +SKIP
    (2, 3)

    """
    if isinstance(old_name, (list, tuple)):
        # Normalize input parameters
        if not isinstance(arg_in_kwargs, (list, tuple)):
            arg_in_kwargs = [arg_in_kwargs] * len(old_name)
        if not isinstance(since, (list, tuple)):
            since = [since] * len(old_name)
        if not isinstance(until, (list, tuple)):
            until = [until] * len(old_name)
        if not isinstance(new_name, (list, tuple)):
            new_name = [new_name] * len(old_name)

        if (
            len(
                {
                    len(old_name),
                    len(new_name),
                    len(since),
                    len(until),
                    len(arg_in_kwargs),
                }
            )
            != 1
        ):
            raise ValueError("All parameters should have the same length")
    else:
        # To allow a uniform approach later on, wrap all arguments in lists.
        old_name = [old_name]
        new_name = [new_name]
        since = [since]
        until = [until]
        arg_in_kwargs = [arg_in_kwargs]

    def deprecator(function):
        # The named arguments of the function.
        arguments = signature(function).parameters
        positions = [None] * len(old_name)

        for i, (o_name, n_name, in_keywords) in enumerate(
            zip(old_name, new_name, arg_in_kwargs)
        ):
            # Determine the position of the argument.
            if in_keywords:
                continue

            if n_name is not None and n_name not in arguments:
                # In case the argument is not found in the list of arguments
                # the only remaining possibility is that it should be caught
                # by some kind of **kwargs argument.
                msg = f'"{n_name}" was not specified in the function '
                msg += "signature. If it was meant to be part of "
                msg += '"**kwargs" then set "arg_in_kwargs" to "True"'
                raise TypeError(msg)

            key = o_name if n_name is None else n_name
            param = arguments[key]

            if param.kind == param.POSITIONAL_OR_KEYWORD:
                key = o_name if n_name is None else n_name
                positions[i] = list(arguments.keys()).index(key)
            elif param.kind == param.KEYWORD_ONLY:
                # These cannot be specified by position.
                positions[i] = None
            else:
                # positional-only argument, varargs, varkwargs or some
                # unknown type:
                msg = f'cannot replace argument "{n_name}" '
                msg += f"of kind {repr(param.kind)}."
                raise TypeError(msg)

        @functools.wraps(function)
        def wrapper(*args, **kwargs):
            for i, (o_name, n_name) in enumerate(zip(old_name, new_name)):
                messages = [
                    f'"{o_name}" was deprecated',
                ]
                if (since[i], until[i]) != ("", ""):
                    messages.append("")
                if since[i]:
                    messages.append(f"* deprecated from version: {since[i]}")
                if until[i]:
                    raise_will_raise = (
                        "Raises" if is_bad_version(until[i]) else "Will raise"
                    )
                    messages.append(
                        f"* {raise_will_raise} {error_class} as of version: {until[i]}"
                    )
                messages.append("")
                message = "\n".join(messages)

                # The only way to have oldkeyword inside the function is
                # that it is passed as kwarg because the oldkeyword
                # parameter was renamed to newkeyword.
                if o_name in kwargs:
                    value = kwargs.pop(o_name)
                    # Check if the newkeyword was given as well.
                    newarg_in_args = (
                        positions[i] is not None and len(args) > positions[i]
                    )
                    newarg_in_kwargs = n_name in kwargs

                    if newarg_in_args or newarg_in_kwargs:
                        msg = f'cannot specify both "{o_name}"'
                        msg += " (deprecated parameter) and "
                        msg += f'"{n_name}" (new parameter name).'
                        raise TypeError(msg)

                    # Pass the value of the old argument with the
                    # name of the new argument to the function
                    key = n_name or o_name
                    kwargs[key] = value

                    if n_name is not None:
                        message += f'* Use argument "{n_name}" instead.'
                    elif alternative:
                        message += f"* Use {alternative} instead."

                    if until[i] and is_bad_version(
                        until[i], version_comparator=version_comparator
                    ):
                        raise error_class(message)
                    warnings.warn(message, warn_class, stacklevel=2)

                # Deprecated keyword without replacement is given as
                # positional argument.
                elif not n_name and positions[i] and len(args) > positions[i]:
                    if alternative:
                        message += f"* Use {alternative} instead."
                    if until[i] and is_bad_version(
                        until[i], version_comparator=version_comparator
                    ):
                        raise error_class(message)

                    warnings.warn(message, warn_class, stacklevel=2)

            return function(*args, **kwargs)

        return wrapper

    return deprecator
