# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

import argparse
from collections import defaultdict

from .deprecation import Deprecated
from .preview import PreviewItem
from .experimental import ExperimentalItem
from .log import get_logger
from .util import CLIError


logger = get_logger(__name__)


class CLIArgumentType(object):

    REMOVE = '---REMOVE---'

    def __init__(self, overrides=None, **kwargs):
        """A base CLI Argument Type that can be applied to multiple command arguments

        :param overrides: The base argument that you are overriding
        :type overrides: knack.arguments.CLIArgumentType
        :param kwargs: Possible values: `options_list`, `validator`, `completer`, `nargs`, `action`, `const`, `default`,
                       `type`, `choices`, `required`, `help`, `metavar`. See /docs/arguments.md.
        """
        if isinstance(overrides, str):
            raise ValueError("Overrides has to be a {} (cannot be a string)".format(CLIArgumentType.__name__))
        options_list = kwargs.get('options_list', None)
        if options_list and isinstance(options_list, str):
            kwargs['options_list'] = [options_list]
        self.settings = {}
        self.update(overrides, **kwargs)

    def update(self, other=None, **kwargs):
        if other:
            self.settings.update(**other.settings)
        self.settings.update(**kwargs)


class CLICommandArgument(object):

    NAMED_ARGUMENTS = ['options_list', 'validator', 'completer', 'arg_group', 'deprecate_info', 'preview_info',
                       'experimental_info', 'default_value_source']

    def __init__(self, dest=None, argtype=None, **kwargs):
        """An argument that has a specific destination parameter.

        :param dest: The parameter that this argument is for
        :type dest: str
        :param argtype: The argument type for this command argument
        :type argtype: knack.arguments.CLIArgumentType
        :param kwargs: see knack.arguments.CLIArgumentType
        """
        self.type = CLIArgumentType(overrides=argtype, **kwargs)
        if dest:
            self.type.update(dest=dest)

        # We'll do an early fault detection to find any instances where we have inconsistent
        # set of parameters for argparse
        if not self.options.get('dest', False):
            raise ValueError('Missing dest')
        if not self.options_list:  # pylint: disable=access-member-before-definition
            self.options_list = ('--{}'.format(self.options['dest'].replace('_', '-')),)

    def __getattr__(self, name):
        if name in self.NAMED_ARGUMENTS:
            return self.type.settings.get(name, None)
        if name == 'name':
            return self.type.settings.get('dest', None)
        if name == 'options':
            return {key: value for key, value in self.type.settings.items()
                    if key != 'options' and key not in self.NAMED_ARGUMENTS and
                    not value == CLIArgumentType.REMOVE}
        if name == 'choices':
            return self.type.settings.get(name, None)

        raise AttributeError(name)

    def __setattr__(self, name, value):  # pylint: disable=inconsistent-return-statements
        if name == 'type':
            return super(CLICommandArgument, self).__setattr__(name, value)
        self.type.settings[name] = value


class ArgumentRegistry(object):
    """A registry of all the arguments registered"""

    def __init__(self):
        self.arguments = defaultdict(lambda: {})

    def register_cli_argument(self, scope, dest, argtype, **kwargs):
        """ Add an argument to the argument registry

        :param scope: The command level to apply the argument registration (e.g. 'mygroup mycommand')
        :type scope: str
        :param dest: The parameter/destination that this argument is for
        :type dest: str
        :param argtype: The argument type for this command argument
        :type argtype: knack.arguments.CLIArgumentType
        :param kwargs: see knack.arguments.CLIArgumentType
        """
        argument = CLIArgumentType(overrides=argtype, **kwargs)
        self.arguments[scope][dest] = argument

    def get_cli_argument(self, command, name):
        """ Get the argument for the command after applying the scope hierarchy

        :param command: The command that we want the argument for
        :type command: str
        :param name: The name of the argument
        :type name: str
        :return: The CLI command after all overrides in the scope hierarchy have been applied
        :rtype: knack.arguments.CLIArgumentType
        """
        parts = command.split()
        result = CLIArgumentType()
        for index in range(0, len(parts) + 1):
            probe = ' '.join(parts[0:index])
            override = self.arguments.get(probe, {}).get(name, None)
            if override:
                result.update(override)
        return result


class ArgumentsContext(object):
    def __init__(self, command_loader, command_scope, **kwargs):  # pylint: disable=unused-argument
        """ Context manager to register arguments

        :param command_loader: The command loader that arguments should be registered into
        :type command_loader: knack.commands.CLICommandsLoader
        :param command_scope: The scope to which arguments in this context apply.
                              More specific scopes will override less specific scopes in the event of a conflict.
        :type command_scope: str
        """
        self.command_loader = command_loader
        self.command_scope = command_scope
        self.is_stale = False

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.is_stale = True

    def _applicable(self):
        if self.command_loader.skip_applicability:
            return True
        return self.command_loader.cli_ctx.invocation.data['command_string'].startswith(self.command_scope)

    def _check_stale(self):
        if self.is_stale:
            message = "command authoring error: argument context '{}' is stale! " \
                      "Check that the subsequent block for has a corresponding `as` " \
                      "statement.".format(self.command_scope)
            logger.error(message)
            raise CLIError(message)

    def _get_parent_class(self, **kwargs):
        # wrap any existing action
        action = kwargs.get('action', None)
        parent_class = argparse.Action

        # action is either a user-defined Action class or a string referring a library-defined Action
        if isinstance(action, type) and issubclass(action, argparse.Action):
            parent_class = action
        elif isinstance(action, str):
            parent_class = self.command_loader.cli_ctx.invocation.parser._registries['action'][action]  # pylint: disable=protected-access
        return parent_class

    def _handle_deprecations(self, argument_dest, **kwargs):

        def _handle_argument_deprecation(deprecate_info):

            parent_class = self._get_parent_class(**kwargs)

            class DeprecatedArgumentAction(parent_class):

                def __call__(self, parser, namespace, values, option_string=None):
                    if not hasattr(namespace, '_argument_deprecations'):
                        setattr(namespace, '_argument_deprecations', [deprecate_info])
                    else:
                        namespace._argument_deprecations.append(deprecate_info)  # pylint: disable=protected-access
                    try:
                        super(DeprecatedArgumentAction, self).__call__(parser, namespace, values, option_string)
                    except NotImplementedError:
                        setattr(namespace, self.dest, values)

            return DeprecatedArgumentAction

        def _handle_option_deprecation(deprecated_options):

            if not isinstance(deprecated_options, list):
                deprecated_options = [deprecated_options]

            parent_class = self._get_parent_class(**kwargs)

            class DeprecatedOptionAction(parent_class):

                def __call__(self, parser, namespace, values, option_string=None):
                    deprecated_opt = next((x for x in deprecated_options if option_string == x.target), None)
                    if deprecated_opt:
                        if not hasattr(namespace, '_argument_deprecations'):
                            setattr(namespace, '_argument_deprecations', [deprecated_opt])
                        else:
                            namespace._argument_deprecations.append(deprecated_opt)  # pylint: disable=protected-access
                    try:
                        super(DeprecatedOptionAction, self).__call__(parser, namespace, values, option_string)
                    except NotImplementedError:
                        setattr(namespace, self.dest, values)

            return DeprecatedOptionAction

        action = kwargs.get('action', None)

        deprecate_info = kwargs.get('deprecate_info', None)
        if deprecate_info:
            deprecate_info.target = deprecate_info.target or argument_dest
            action = _handle_argument_deprecation(deprecate_info)
        deprecated_opts = [x for x in kwargs.get('options_list', []) if isinstance(x, Deprecated)]
        if deprecated_opts:
            action = _handle_option_deprecation(deprecated_opts)
        return action

    def _handle_previews(self, argument_dest, **kwargs):

        if not kwargs.get('is_preview', False):
            return kwargs

        def _handle_argument_preview(preview_info):

            parent_class = self._get_parent_class(**kwargs)

            class PreviewArgumentAction(parent_class):

                def __call__(self, parser, namespace, values, option_string=None):
                    if not hasattr(namespace, '_argument_previews'):
                        setattr(namespace, '_argument_previews', [preview_info])
                    else:
                        namespace._argument_previews.append(preview_info)  # pylint: disable=protected-access
                    try:
                        super(PreviewArgumentAction, self).__call__(parser, namespace, values, option_string)
                    except NotImplementedError:
                        setattr(namespace, self.dest, values)

            return PreviewArgumentAction

        def _get_preview_arg_message(self):
            return "{} '{}' is in preview. It may be changed/removed in a future release.".format(
                self.object_type.capitalize(), self.target)

        options_list = kwargs.get('options_list', None)
        object_type = 'argument'

        if options_list is None:
            # convert argument dest
            target = '--{}'.format(argument_dest.replace('_', '-'))
        elif options_list:
            target = sorted(options_list, key=len)[-1]
        else:
            # positional argument
            target = kwargs.get('metavar', '<{}>'.format(argument_dest.upper()))
            object_type = 'positional argument'

        preview_info = PreviewItem(
            cli_ctx=self.command_loader.cli_ctx,
            target=target,
            object_type=object_type,
            message_func=_get_preview_arg_message
        )
        kwargs['preview_info'] = preview_info
        kwargs['action'] = _handle_argument_preview(preview_info)
        return kwargs

    def _handle_experimentals(self, argument_dest, **kwargs):

        if not kwargs.get('is_experimental', False):
            return kwargs

        def _handle_argument_experimental(experimental_info):

            parent_class = self._get_parent_class(**kwargs)

            class ExperimentalArgumentAction(parent_class):

                def __call__(self, parser, namespace, values, option_string=None):
                    if not hasattr(namespace, '_argument_experimentals'):
                        setattr(namespace, '_argument_experimentals', [experimental_info])
                    else:
                        namespace._argument_experimentals.append(experimental_info)  # pylint: disable=protected-access
                    try:
                        super(ExperimentalArgumentAction, self).__call__(parser, namespace, values, option_string)
                    except NotImplementedError:
                        setattr(namespace, self.dest, values)

            return ExperimentalArgumentAction

        def _get_experimental_arg_message(self):
            return "{} '{}' is experimental and not covered by customer support. " \
                   "Please use with discretion.".format(self.object_type.capitalize(), self.target)

        options_list = kwargs.get('options_list', None)
        object_type = 'argument'

        if options_list is None:
            # convert argument dest
            target = '--{}'.format(argument_dest.replace('_', '-'))
        elif options_list:
            target = sorted(options_list, key=len)[-1]
        else:
            # positional argument
            target = kwargs.get('metavar', '<{}>'.format(argument_dest.upper()))
            object_type = 'positional argument'

        experimental_info = ExperimentalItem(
            self.command_loader.cli_ctx,
            target=target,
            object_type=object_type,
            message_func=_get_experimental_arg_message
        )
        kwargs['experimental_info'] = experimental_info
        kwargs['action'] = _handle_argument_experimental(experimental_info)
        return kwargs

    # pylint: disable=inconsistent-return-statements
    def deprecate(self, **kwargs):

        def _get_deprecated_arg_message(self):
            msg = "{} '{}' has been deprecated and will be removed ".format(
                self.object_type, self.target).capitalize()
            if self.expiration:
                msg += "in version '{}'.".format(self.expiration)
            else:
                msg += 'in a future release.'
            if self.redirect:
                msg += " Use '{}' instead.".format(self.redirect)
            return msg

        self._check_stale()
        if not self._applicable():
            return

        target = kwargs.get('target', '')
        kwargs['object_type'] = 'option' if target.startswith('-') else 'argument'
        kwargs['message_func'] = _get_deprecated_arg_message
        return Deprecated(self.command_loader.cli_ctx, **kwargs)

    def argument(self, argument_dest, arg_type=None, **kwargs):
        """ Register an argument for the given command scope using a knack.arguments.CLIArgumentType

        :param argument_dest: The destination argument to add this argument type to
        :type argument_dest: str
        :param arg_type: Predefined CLIArgumentType definition to register, as modified by any provided kwargs.
        :type arg_type: knack.arguments.CLIArgumentType
        :param kwargs: Possible values: `options_list`, `validator`, `completer`, `nargs`, `action`, `const`, `default`,
                       `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `is_experimental`,
                       `deprecate_info`. See /docs/arguments.md.
        """
        self._check_stale()
        if not self._applicable():
            return

        deprecate_action = self._handle_deprecations(argument_dest, **kwargs)
        if deprecate_action:
            kwargs['action'] = deprecate_action

        is_preview = kwargs.get('is_preview', False)
        is_experimental = kwargs.get('is_experimental', False)

        if is_preview and is_experimental:
            from .commands import PREVIEW_EXPERIMENTAL_CONFLICT_ERROR
            raise CLIError(PREVIEW_EXPERIMENTAL_CONFLICT_ERROR.format('argument', argument_dest))

        kwargs = self._handle_previews(argument_dest, **kwargs)
        kwargs = self._handle_experimentals(argument_dest, **kwargs)

        self.command_loader.argument_registry.register_cli_argument(self.command_scope,
                                                                    argument_dest,
                                                                    arg_type,
                                                                    **kwargs)

    def positional(self, argument_dest, arg_type=None, **kwargs):
        """ Register a positional argument for the given command scope using a knack.arguments.CLIArgumentType

        :param argument_dest: The destination argument to add this argument type to
        :type argument_dest: str
        :param arg_type: Predefined CLIArgumentType definition to register, as modified by any provided kwargs.
        :type arg_type: knack.arguments.CLIArgumentType
        :param kwargs: Possible values: `validator`, `completer`, `nargs`, `action`, `const`, `default`,
                       `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `is_experimental`,
                       `deprecate_info`. See /docs/arguments.md.
        """
        self._check_stale()
        if not self._applicable():
            return

        if self.command_scope not in self.command_loader.command_table:
            raise ValueError("command authoring error: positional argument '{}' cannot be registered to a group-level "
                             "scope '{}'. It must be registered to a specific command.".format(
                                 argument_dest, self.command_scope))

        # Before adding the new positional arg, ensure that there are no existing positional arguments
        # registered for this command.
        command_args = self.command_loader.argument_registry.arguments[self.command_scope]
        positional_args = {k: v for k, v in command_args.items() if v.settings.get('options_list') == []}
        if positional_args and argument_dest not in positional_args:
            raise CLIError("command authoring error: commands may have, at most, one positional argument. '{}' already "
                           "has positional argument: {}.".format(self.command_scope, ' '.join(positional_args.keys())))

        kwargs['options_list'] = []

        deprecate_action = self._handle_deprecations(argument_dest, **kwargs)
        if deprecate_action:
            kwargs['action'] = deprecate_action

        kwargs = self._handle_previews(argument_dest, **kwargs)
        kwargs = self._handle_experimentals(argument_dest, **kwargs)

        self.command_loader.argument_registry.register_cli_argument(self.command_scope,
                                                                    argument_dest,
                                                                    arg_type,
                                                                    **kwargs)

    def ignore(self, argument_dest, **kwargs):
        """ Register an argument with type knack.arguments.ignore_type (hidden/ignored)

        :param argument_dest: The destination argument to apply the ignore type to
        :type argument_dest: str
        """
        self._check_stale()
        if not self._applicable():
            return

        dest_option = ['--__{}'.format(argument_dest.upper())]
        self.argument(argument_dest, arg_type=ignore_type, options_list=dest_option, **kwargs)

    def extra(self, argument_dest, **kwargs):
        """Register extra parameters for the given command. Typically used to augment auto-command built
        commands to add more parameters than the specific SDK method introspected.

        :param argument_dest: The destination argument to add this argument type to
        :type argument_dest: str
        :param kwargs: Possible values: `options_list`, `validator`, `completer`, `nargs`, `action`, `const`, `default`,
                       `type`, `choices`, `required`, `help`, `metavar`, `is_preview`, `is_experimental`,
                       `deprecate_info`. See /docs/arguments.md.
        """
        self._check_stale()
        if not self._applicable():
            return

        if self.command_scope in self.command_loader.command_group_table:
            raise ValueError("command authoring error: extra argument '{}' cannot be registered to a group-level "
                             "scope '{}'. It must be registered to a specific command.".format(
                                 argument_dest, self.command_scope))

        deprecate_action = self._handle_deprecations(argument_dest, **kwargs)
        if deprecate_action:
            kwargs['action'] = deprecate_action

        kwargs = self._handle_previews(argument_dest, **kwargs)
        kwargs = self._handle_experimentals(argument_dest, **kwargs)

        self.command_loader.extra_argument_registry[self.command_scope][argument_dest] = CLICommandArgument(
            argument_dest, **kwargs)


class IgnoreAction(argparse.Action):  # pylint: disable=too-few-public-methods
    """ Show the argument as unrecognized if it is called """

    def __call__(self, parser, namespace, values, option_string=None):
        raise argparse.ArgumentError(None, 'unrecognized argument: {} {}'.format(
            option_string, values or ''))


class CaseInsensitiveList(list):
    """ Determine if a choice is in a choice list in a case-insensitive manner """

    def __contains__(self, other):
        return next((True for x in self if other.lower() == x.lower()), False)


def enum_choice_list(data):
    """ Creates the argparse choices and type kwargs for a supplied enum type or list of strings """

    # transform enum types, otherwise assume list of string choices
    if not data:
        return {}
    try:
        choices = [x.value for x in data]
    except AttributeError:
        choices = data

    def _type(value):
        return next((x for x in choices if x.lower() == value.lower()), value) if value else value
    params = {
        'choices': CaseInsensitiveList(choices),
        'type': _type
    }
    return params

# GLOBAL ARGUMENT DEFINITIONS


ignore_type = CLIArgumentType(
    help=argparse.SUPPRESS,
    nargs='?',
    action=IgnoreAction,
    required=False)