# Copied and simplified from
# https://github.com/briancurtin/deprecation/blob/master/deprecation.py
# 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.

import collections
import functools
import textwrap
import typing as t
import warnings

#: Location where the details are added to a deprecated docstring
#: When set to ``"bottom"``, the details are appended to the end.
#: When set to ``"top"``, the details are inserted between the
#: summary line and docstring contents.
message_location = "bottom"


class DeprecatedWarning(DeprecationWarning):
    """A warning class for deprecated methods
    This is a specialization of the built-in :class:`DeprecationWarning`,
    adding parameters that allow us to get information into the __str__
    that ends up being sent through the :mod:`warnings` system.
    The attributes aren't able to be retrieved after the warning gets
    raised and passed through the system as only the class--not the
    instance--and message are what gets preserved.
    """

    def __init__(
        self,
        function: str,
        deprecated_in: str,
        removed_in: str,
        details: str = "",
    ):
        # NOTE: The docstring only works for this class if it appears up
        # near the class name, not here inside __init__
        self.function = function
        self.deprecated_in = deprecated_in
        self.removed_in = removed_in
        self.details = details
        super(DeprecatedWarning, self).__init__(
            function, deprecated_in, removed_in, details
        )

    def __str__(self) -> str:
        # Use a defaultdict to give us the empty string
        # when a part isn't included.
        parts: t.DefaultDict[str, t.Any] = collections.defaultdict(str)
        parts["function"] = self.function

        if self.deprecated_in:
            parts["deprecated"] = " as of %s" % self.deprecated_in
        if self.removed_in:
            parts["removed"] = " and will be removed in {}".format(
                self.removed_in
            )
        if any([self.deprecated_in, self.removed_in, self.details]):
            parts["period"] = "."
        if self.details:
            parts["details"] = " %s" % self.details

        return (
            "%(function)s is deprecated%(deprecated)s%(removed)s"
            "%(period)s%(details)s" % (parts)
        )


def deprecated(
    deprecated_in: str,
    removed_in: str,
    details: str = "",
) -> t.Callable:
    """Decorate a function to signify its deprecation
    This function wraps a method that will soon be removed and does two things:
        * The docstring of the method will be modified to include a notice
          about deprecation, e.g., "Deprecated since 0.9.11. Use foo instead."
        * Raises a :class:`~deprecation.DeprecatedWarning`
          via the :mod:`warnings` module, which is a subclass of the built-in
          :class:`DeprecationWarning`. Note that built-in
          :class:`DeprecationWarning`s are ignored by default, so for users
          to be informed of said warnings they will need to enable them--see
          the :mod:`warnings` module documentation for more details.
    :param deprecated_in: The version at which the decorated method is
                          considered deprecated. This will usually be the
                          next version to be released when the decorator is
                          added. The default is **None**, which effectively
                          means immediate deprecation. If this is not
                          specified, then the `removed_in` and
                          `current_version` arguments are ignored.
    :param removed_in: The default is **None**,
                       specifying that the function is not currently planned
                       to be removed.
                       Note: This parameter cannot be set to a value if
                       `deprecated_in=None`.
    :param details: Extra details to be added to the method docstring and
                    warning. For example, the details may point users to
                    a replacement method, such as "Use the foo_bar
                    method instead". By default there are no details.
    """
    # Only warn when it's appropriate. There may be cases when it makes sense
    # to add this decorator before a formal deprecation period begins.
    # In CPython, PendingDeprecatedWarning gets used in that period,
    # so perhaps mimick that at some point.

    def _function_wrapper(function: t.Callable) -> t.Callable:
        # Everything *should* have a docstring, but just in case...
        existing_docstring = function.__doc__ or ""

        # The various parts of this decorator being optional makes for
        # a number of ways the deprecation notice could go. The following
        # makes for a nicely constructed sentence with or without any
        # of the parts.
        parts = {
            "deprecated_in": " %s" % deprecated_in if deprecated_in else "",
            "removed_in": "\n   This will be removed in {}.".format(removed_in)
            if removed_in
            else "",
            "details": " %s" % details if details else "",
        }

        deprecation_note = (
            ".. deprecated::{deprecated_in}" "{removed_in}{details}".format(
                **parts
            )
        )

        # default location for insertion of deprecation note
        loc = 1

        # split docstring at first occurrence of newline
        string_list = existing_docstring.split("\n", 1)

        if len(string_list) > 1:
            # With a multi-line docstring, when we modify
            # existing_docstring to add our deprecation_note,
            # if we're not careful we'll interfere with the
            # indentation levels of the contents below the
            # first line, or as PUP 257 calls it, the summary
            # line. Since the summary line can start on the
            # same line as the """, dedenting the whole thing
            # won't help. Split the summary and contents up,
            # dedent the contents independently, then join
            # summary, dedent'ed contents, and our
            # deprecation_note.

            # in-place dedent docstring content
            string_list[1] = textwrap.dedent(string_list[1])

            # we need another newline
            string_list.insert(loc, "\n")

            # change the message_location if we add to end of docstring
            # do this always if not "top"
            if message_location != "top":
                loc = 3

        # insert deprecation note and dual newline
        string_list.insert(loc, deprecation_note)
        string_list.insert(loc, "\n\n")
        function.__doc__ = "".join(string_list)

        @functools.wraps(function)
        def _inner(*args: t.Any, **kwargs: t.Any) -> t.Any:
            the_warning = DeprecatedWarning(
                function.__name__, deprecated_in, removed_in, details
            )
            warnings.warn(
                the_warning, category=DeprecationWarning, stacklevel=2
            )
            return function(*args, **kwargs)

        return _inner

    return _function_wrapper


def fail_if_not_removed(method: t.Callable) -> t.Callable:
    """Decorate a test method to track removal of deprecated code
    This decorator catches :class:`~deprecation.UnsupportedWarning`
    warnings that occur during testing and causes unittests to fail,
    making it easier to keep track of when code should be removed.
    :raises: :class:`AssertionError` if an
             :class:`~deprecation.UnsupportedWarning`
             is raised while running the test method.
    """

    # NOTE: Unless this is named test_inner, nose won't work
    # properly.See
    @functools.wraps(method)
    def test_inner(*args: t.Any, **kwargs: t.Any) -> t.Any:
        with warnings.catch_warnings(record=True) as caught_warnings:
            warnings.simplefilter("always")
            rv = method(*args, **kwargs)
        for warning in caught_warnings:
            if warning.category == DeprecatedWarning:
                raise AssertionError(
                    (
                        "%s uses a function that should be removed: %s"
                        % (method, str(warning.message))
                    )
                )
        return rv

    return test_inner
