import inspect
import warnings
from collections import namedtuple
from collections.abc import Iterable
from copy import deepcopy
from functools import update_wrapper, wraps

from .base import version_str_as_tuple, MINIMUM_COMPATIBILITY_MODE

try:
    from deprecated.sphinx import deprecated as lib_deprecated
except ImportError:
    warnings.warn(
        "Deprecated not found. Please install ldclient's dependencies by installing it as a package"
    )
    from .base import _noop_decorator as lib_deprecated


def _convert_show_poses(function):
    """
    Decorator to ensure backwards compatibility with prior API arguments.

    :type function: :class:`function`
    :param function: function to convert show_poses parameter

    :rtype: :class:`function`
    :return: The converted function
    """

    def convert_parameters(self, live_report_id, params=None, *args, **kwargs):
        if params and 'show_poses' not in params and 'report_level' in params:
            return function(self, live_report_id, params, *args, **kwargs)

        params = deepcopy(params) or {}
        report_level = 'POSE' if params.get('show_poses') else params.get('report_level')

        if not report_level:
            warnings.warn("A 'report_level' is required, fetching it from LiveReport.",
                          FutureWarning)
            report_level = getattr(self.live_report_metadata(live_report_id), 'report_level',
                                   'PARENT')

        if 'show_poses' in params:
            del params['show_poses']
            warnings.warn(
                "The 'show_poses' parameter is deprecated and will soon be removed from LD."
                "Please use { 'report_level': 'POSE' } instead of { 'show_poses': True }",
                FutureWarning)

        if report_level != params.get('report_level'):
            params['report_level'] = report_level

        return function(self, live_report_id, params, *args, **kwargs)

    return update_wrapper(convert_parameters, function)


DefaultMessageKey = namedtuple('DefaultMessageKey', ['hasReplacement'])
HARD_ERROR_MSG = "Removal grace period has expired, please remove offending definition of {}"
DEFAULT_MSGS = {
    DefaultMessageKey(hasReplacement=True): "{func_name}: Please switch to using :meth:`{replacement}` by compatibility mode {version}",
    DefaultMessageKey(hasReplacement=False): "{func_name}: Please check the release notes about upgrading by compatibility mode {version}"
}


def deprecated(version, reason=None, replacement=None):
    """
    Decorator to mark existing functions as deprecated and remind us to remove
    them at the correct time.

    :type reason: str
    :param reason: The message to display when the function is invoked, as well as
                to be displayed in the documentation. This is optional, if not
                supplied, then a generic message will be used

    :type version: str
    :param version: The version that this function was deprecated in.  If a user is
                using compatibility mode >= version, they can not use the decorated function

    :type replacement: str
    :param replacement: The name of the new function that users should switch to.
                        This is optional, but if it is not supplied, then msg must
                        be supplied.

    :rtype: :class:`function`
    :return: The deprecated function
    """

    # receives the annotated function as arguments, returns the function that gets
    # bound to the original name
    def wrap(function):
        # If the dev didn't supply their own message, then try to generate one
        # based on the rest of the parameters
        if reason is None and replacement is None:
            raise ValueError('you need to supply one of reason or replacement')
        if reason is None:
            message = DEFAULT_MSGS[DefaultMessageKey(hasReplacement=replacement is not None)]
        else:
            message = reason
        # N.B. the removal version doesn't go into user facing messages, since it would be best
        # for them to think that it *will* get removed in the earlier version
        msg_dict = {'func_name': function.__name__, 'version': version, 'replacement': replacement}
        # N.B. The custom message also gets passed into format, so a single message
        # string can be shared between multiple functions
        message = message.format(**msg_dict)

        # N.B. This code is run at load time, and thus is guaranteed to fail in tests
        # if we are past the deprecation version
        version_tuple = version_str_as_tuple(version)
        min_compatibility_mode_tuple = version_str_as_tuple(MINIMUM_COMPATIBILITY_MODE)
        if min_compatibility_mode_tuple >= version_tuple:
            raise AttributeError(message)

        @lib_deprecated(version=version, reason=message)
        def lib_wrapped_function(*args, **kwargs):
            return function(*args, **kwargs)

        lib_wrapped_function.__doc__ = (function.__doc__
                                        or '') + '\n' + (lib_wrapped_function.__doc__ or '')

        # The function that gets bound to the original name
        def do_warning(*args, **kwargs):
            if len(args) == 0:
                raise AttributeError("deprecated decorator can only be put on functions")
            _self = args[0]
            curr_compatibility_mode = _self.compatibility_mode
            if curr_compatibility_mode >= version_tuple:
                if not ignore_deprecation(curr_compatibility_mode, function.__name__, *args):
                    raise AttributeError(message)
            warnings.warn(message, DeprecationWarning, stacklevel=2)
            return lib_wrapped_function(*args, **kwargs)

        # Update some special member variables on the wrapped function so that it
        # acts more like the original
        result = update_wrapper(do_warning, lib_wrapped_function)
        return result

    return wrap


def ignore_deprecation(cur_version, name, *args):
    """
    In the given version, the feature will stop working, HOWEVER, there will always
    be users who don't see the warnings, and will find out when their code breaks.
    Rather than having a hard upgrade blocker which involves the actual migration,
    this mechanism allows failures to be suppressed until the proper changes can
    be made.

    The current way of overriding deprecation failures is to call
    api._override_deprecation_failure((8, 10)). HOWEVER, that function is
     private, which means that users will need to call the mangled version of the name.

     :param cur_version: The current version of LDClient that the function is being called by
     :type cur_version: :class:`tuple` of :class:`int`

     :param name: Name of the function that is deprecated
     :type name: :class:`str`

     :rtype: :class:`bool`
     :return: True if the deprecation failure should be ignored, False if not.
    """
    if len(args) == 0:
        # NOTE(mrosenbe) We can't effectively delay the deprecation of static methods
        # with a value on the ldclient object.
        return False
    _self = args[0]
    if cur_version == _self.ignore_deprecation_failure_for_version:
        return True
    return False


def _enforce_string_ids(*id_param_names):
    """
    Decorator to enforce that identifiers are passed in as strings.  If they are not strings, they will be casted to
    strings, and a warning will be logged.

    :type id_param_names: :class:`str`
    :param id_param_names: name(s) of parameter(s) that should be strings or collection of strings

    :rtype: :class:`function`
    :return: The function with the specified parameters casted to strings or collection of strings
    """
    message = "All identifiers should be strings. When calling {function}, please input {param} as a {type}."

    def decorator(f):
        params = list(inspect.signature(f).parameters.keys())
        id_param_indices = []
        for id_param in id_param_names:
            id_param_indices.append(params.index(id_param))

        @wraps(f)
        def wrapper(*args, **kwargs):
            _self = args[0]
            new_args = []
            for i in range(len(args)):
                arg = args[i]
                if i not in id_param_indices:
                    new_args.append(arg)
                    continue
                is_proper_type = arg is None or isinstance(
                    arg, str) or (isinstance(arg, Iterable)
                                  and all(isinstance(e, str) for e in arg))
                if is_proper_type:
                    new_args.append(arg)
                    continue
                if isinstance(arg, Iterable):
                    new_args.append([str(x) for x in arg])
                    warnings.warn(
                        message.format(function=f.__name__,
                                       param=params[i],
                                       type='collection of strings'), UserWarning)
                else:
                    new_args.append(str(arg))
                    warnings.warn(
                        message.format(function=f.__name__, param=params[i], type='string'),
                        UserWarning)
            new_kwargs = {}
            for key in kwargs:
                value = kwargs[key]
                if key not in id_param_names:
                    new_kwargs[key] = value
                    continue
                is_proper_type = value is None or isinstance(
                    value, str) or (isinstance(value, Iterable)
                                    and all(isinstance(e, str) for e in value))
                if is_proper_type:
                    new_kwargs[key] = value
                    continue
                if isinstance(value, Iterable):
                    new_kwargs[key] = [str(x) for x in value]
                    warnings.warn(
                        message.format(function=f.__name__, param=key,
                                       type='collection of strings'), UserWarning)
                else:
                    new_kwargs[key] = str(value)
                    warnings.warn(message.format(function=f.__name__, param=key, type='string'),
                                  UserWarning)
            return f(*args, **kwargs) if _self.disable_string_identifier_conversion else f(
                *new_args, **new_kwargs)

        return wrapper

    return decorator
