# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:

# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.

"""Contains the Command class, a skeleton for a command."""

import inspect
import collections

from PyQt5.QtWebKit import QWebSettings

from qutebrowser.commands import cmdexc, argparser
from qutebrowser.utils import log, utils, message, debug, docutils, objreg


class Command:

    """Base skeleton for a command.

    Attributes:
        name: The main name of the command.
        maxsplit: The maximum amount of splits to do for the commandline, or
                  None.
        hide: Whether to hide the arguments or not.
        desc: The description of the command.
        handler: The handler function to call.
        completion: Completions to use for arguments, as a list of strings.
        debug: Whether this is a debugging command (only shown with --debug).
        parser: The ArgumentParser to use to parse this command.
        special_params: A dict with the names of the special parameters as
                        values.
        flags_with_args: A list of flags which take an argument.
        _type_conv: A mapping of conversion functions for arguments.
        _name_conv: A mapping of argument names to parameter names.
        _needs_js: Whether the command needs javascript enabled
        _modes: The modes the command can be executed in.
        _not_modes: The modes the command can not be executed in.
        _count: The count set for the command.
        _instance: The object to bind 'self' to.
        _scope: The scope to get _instance for in the object registry.

    Class attributes:
        AnnotationInfo: Named tuple for info from an annotation.
    """

    AnnotationInfo = collections.namedtuple('AnnotationInfo',
                                            ['kwargs', 'type', 'name', 'flag',
                                             'special'])

    def __init__(self, name, maxsplit, hide, instance, completion, modes,
                 not_modes, needs_js, is_debug, ignore_args,
                 handler, scope):
        # I really don't know how to solve this in a better way, I tried.
        # pylint: disable=too-many-arguments,too-many-locals
        self.name = name
        self.maxsplit = maxsplit
        self.hide = hide
        self._instance = instance
        self.completion = completion
        self._modes = modes
        self._not_modes = not_modes
        self._scope = scope
        self._needs_js = needs_js
        self.debug = is_debug
        self.ignore_args = ignore_args
        self.handler = handler
        self.docparser = docutils.DocstringParser(handler)
        self.parser = argparser.ArgumentParser(
            name, description=self.docparser.short_desc,
            epilog=self.docparser.long_desc)
        self.parser.add_argument('-h', '--help', action=argparser.HelpAction,
                                 default=argparser.SUPPRESS, nargs=0,
                                 help=argparser.SUPPRESS)
        self._check_func()
        self.opt_args = collections.OrderedDict()
        self.namespace = None
        self._count = None
        self.pos_args = []
        self.special_params = {'count': None, 'win_id': None}
        self.desc = None
        self.flags_with_args = []
        self._type_conv = {}
        self._name_conv = {}
        self._inspect_func()

    def _check_prerequisites(self, win_id):
        """Check if the command is permitted to run currently.

        Args:
            win_id: The window ID the command is run in.
        """
        mode_manager = objreg.get('mode-manager', scope='window',
                                  window=win_id)
        curmode = mode_manager.mode()
        if self._modes is not None and curmode not in self._modes:
            mode_names = '/'.join(mode.name for mode in self._modes)
            raise cmdexc.PrerequisitesError(
                "{}: This command is only allowed in {} mode.".format(
                    self.name, mode_names))
        elif self._not_modes is not None and curmode in self._not_modes:
            mode_names = '/'.join(mode.name for mode in self._not_modes)
            raise cmdexc.PrerequisitesError(
                "{}: This command is not allowed in {} mode.".format(
                    self.name, mode_names))
        if self._needs_js and not QWebSettings.globalSettings().testAttribute(
                QWebSettings.JavascriptEnabled):
            raise cmdexc.PrerequisitesError(
                "{}: This command needs javascript enabled.".format(self.name))

    def _check_func(self):
        """Make sure the function parameters don't violate any rules."""
        signature = inspect.signature(self.handler)
        if 'self' in signature.parameters and self._instance is None:
            raise TypeError("{} is a class method, but instance was not "
                            "given!".format(self.name[0]))
        elif 'self' not in signature.parameters and self._instance is not None:
            raise TypeError("{} is not a class method, but instance was "
                            "given!".format(self.name[0]))
        elif inspect.getfullargspec(self.handler).varkw is not None:
            raise TypeError("{}: functions with varkw arguments are not "
                            "supported!".format(self.name[0]))

    def _get_typeconv(self, param, typ):
        """Get a dict with a type conversion for the parameter.

        Args:
            param: The inspect.Parameter to handle.
            typ: The type of the parameter.
        """
        type_conv = {}
        if utils.is_enum(typ):
            type_conv[param.name] = argparser.enum_getter(typ)
        elif isinstance(typ, tuple):
            if param.default is not inspect.Parameter.empty:
                typ = typ + (type(param.default),)
            type_conv[param.name] = argparser.multitype_conv(typ)
        return type_conv

    def _get_nameconv(self, param, annotation_info):
        """Get a dict with a name conversion for the paraeter.

        Args:
            param: The inspect.Parameter to handle.
            annotation_info: The AnnotationInfo tuple for the parameter.
        """
        d = {}
        if annotation_info.name is not None:
            d[param.name] = annotation_info.name
        return d

    def _inspect_special_param(self, param, annotation_info):
        """Check if the given parameter is a special one.

        Args:
            param: The inspect.Parameter to handle.
            annotation_info: The AnnotationInfo tuple for the parameter.

        Return:
            True if the parameter is special, False otherwise.
        """
        special = annotation_info.special
        if special == 'count':
            if self.special_params['count'] is not None:
                raise ValueError("Registered multiple parameters ({}/{}) as "
                                 "count!".format(self.special_params['count'],
                                                 param.name))
            if param.default is inspect.Parameter.empty:
                raise TypeError("{}: handler has count parameter "
                                "without default!".format(self.name))
            self.special_params['count'] = param.name
            return True
        elif special == 'win_id':
            if self.special_params['win_id'] is not None:
                raise ValueError("Registered multiple parameters ({}/{}) as "
                                 "win_id!".format(
                                     self.special_params['win_id'],
                                     param.name))
            self.special_params['win_id'] = param.name
            return True
        elif special is None:
            return False
        else:
            raise ValueError("{}: Invalid value '{}' for 'special' "
                             "annotation!".format(self.name, special))

    def _inspect_func(self):
        """Inspect the function to get useful informations from it.

        Sets instance attributes (desc, type_conv, name_conv) based on the
        informations.
        """
        signature = inspect.signature(self.handler)
        doc = inspect.getdoc(self.handler)
        if doc is not None:
            self.desc = doc.splitlines()[0].strip()
        else:
            self.desc = ""
        if not self.ignore_args:
            for param in signature.parameters.values():
                annotation_info = self._parse_annotation(param)
                if param.name == 'self':
                    continue
                if self._inspect_special_param(param, annotation_info):
                    continue
                typ = self._get_type(param, annotation_info)
                kwargs = self._param_to_argparse_kwargs(param, annotation_info)
                args = self._param_to_argparse_args(param, annotation_info)
                self._type_conv.update(self._get_typeconv(param, typ))
                self._name_conv.update(
                    self._get_nameconv(param, annotation_info))
                callsig = debug.format_call(
                    self.parser.add_argument, args, kwargs,
                    full=False)
                log.commands.vdebug('Adding arg {} of type {} -> {}'.format(
                    param.name, typ, callsig))
                self.parser.add_argument(*args, **kwargs)

    def _param_to_argparse_kwargs(self, param, annotation_info):
        """Get argparse keyword arguments for a parameter.

        Args:
            param: The inspect.Parameter object to get the args for.
            annotation_info: An AnnotationInfo tuple for the parameter.

        Return:
            A kwargs dict.
        """
        kwargs = {}
        typ = self._get_type(param, annotation_info)

        try:
            kwargs['help'] = self.docparser.arg_descs[param.name]
        except KeyError:
            pass

        if isinstance(typ, tuple):
            pass
        elif utils.is_enum(typ):
            kwargs['choices'] = [e.name.replace('_', '-') for e in typ]
            kwargs['metavar'] = param.name
        elif typ is bool:
            kwargs['action'] = 'store_true'
        elif typ is not None:
            kwargs['type'] = typ

        if param.kind == inspect.Parameter.VAR_POSITIONAL:
            kwargs['nargs'] = '+'
        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
            kwargs['default'] = param.default
        elif typ is not bool and param.default is not inspect.Parameter.empty:
            kwargs['default'] = param.default
            kwargs['nargs'] = '?'
        kwargs.update(annotation_info.kwargs)
        return kwargs

    def _param_to_argparse_args(self, param, annotation_info):
        """Get argparse positional arguments for a parameter.

        Args:
            param: The inspect.Parameter object to get the args for.
            annotation_info: An AnnotationInfo tuple for the parameter.

        Return:
            A list of args.
        """
        args = []
        name = annotation_info.name or param.name
        shortname = annotation_info.flag or param.name[0]
        if len(shortname) != 1:
            raise ValueError("Flag '{}' of parameter {} (command {}) must be "
                             "exactly 1 char!".format(shortname, name,
                                                      self.name))
        typ = self._get_type(param, annotation_info)
        if typ is bool or param.kind == inspect.Parameter.KEYWORD_ONLY:
            long_flag = '--{}'.format(name)
            short_flag = '-{}'.format(shortname)
            args.append(long_flag)
            args.append(short_flag)
            self.opt_args[param.name] = long_flag, short_flag
            if param.kind == inspect.Parameter.KEYWORD_ONLY:
                self.flags_with_args.append(param.name)
        else:
            args.append(name)
            self.pos_args.append((param.name, name))
        return args

    def _parse_annotation(self, param):
        """Get argparse arguments and type from a parameter annotation.

        Args:
            param: A inspect.Parameter instance.

        Return:
            An AnnotationInfo namedtuple.
                kwargs: A dict of keyword args to add to the
                        argparse.ArgumentParser.add_argument call.
                typ: The type to use for this argument.
                flag: The short name/flag if overridden.
                name: The long name if overridden.
        """
        info = {'kwargs': {}, 'type': None, 'flag': None, 'name': None,
                'special': None}
        if param.annotation is not inspect.Parameter.empty:
            log.commands.vdebug("Parsing annotation {}".format(
                param.annotation))
            for field in ('type', 'flag', 'name', 'special'):
                if field in param.annotation:
                    info[field] = param.annotation[field]
            if 'nargs' in param.annotation:
                info['kwargs'] = {'nargs': param.annotation['nargs']}
        return self.AnnotationInfo(**info)

    def _get_type(self, param, annotation_info):
        """Get the type of an argument from its default value or annotation.

        Args:
            param: The inspect.Parameter to look at.
            annotation_info: An AnnotationInfo tuple which overrides the type.
        """
        if annotation_info.type is not None:
            return annotation_info.type
        elif param.default is None or param.default is inspect.Parameter.empty:
            return None
        else:
            return type(param.default)

    def _get_self_arg(self, win_id, param, args):
        """Get the self argument for a function call.

        Arguments:
            win_id: The window id this command should be executed in.
            param: The count parameter.
            args: The positional argument list. Gets modified directly.
        """
        assert param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD
        if self._scope is not 'window':
            win_id = None
        obj = objreg.get(self._instance, scope=self._scope,
                         window=win_id)
        args.append(obj)

    def _get_count_arg(self, param, args, kwargs):
        """Add the count argument to a function call.

        Arguments:
            param: The count parameter.
            args: The positional argument list. Gets modified directly.
            kwargs: The keyword argument dict. Gets modified directly.
        """
        if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
            if self._count is not None:
                args.append(self._count)
            else:
                args.append(param.default)
        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
            if self._count is not None:
                kwargs[param.name] = self._count
        else:
            raise TypeError("{}: invalid parameter type {} for argument "
                            "{!r}!".format(self.name, param.kind, param.name))

    def _get_win_id_arg(self, win_id, param, args, kwargs):
        """Add the win_id argument to a function call.

        Arguments:
            win_id: The window ID to add.
            param: The count parameter.
            args: The positional argument list. Gets modified directly.
            kwargs: The keyword argument dict. Gets modified directly.
        """
        if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
            args.append(win_id)
        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
            kwargs[param.name] = win_id
        else:
            raise TypeError("{}: invalid parameter type {} for argument "
                            "{!r}!".format(self.name, param.kind, param.name))

    def _get_param_name_and_value(self, param):
        """Get the converted name and value for an inspect.Parameter."""
        name = self._name_conv.get(param.name, param.name)
        value = getattr(self.namespace, name)
        if param.name in self._type_conv:
            # We convert enum types after getting the values from
            # argparse, because argparse's choices argument is
            # processed after type conversation, which is not what we
            # want.
            value = self._type_conv[param.name](value)
        return name, value

    def _get_call_args(self, win_id):  # noqa
        """Get arguments for a function call.

        Args:
            win_id: The window id this command should be executed in.

        Return:
            An (args, kwargs) tuple.
        """

        args = []
        kwargs = {}
        signature = inspect.signature(self.handler)

        if self.ignore_args:
            if self._instance is not None:
                param = list(signature.parameters.values())[0]
                self._get_self_arg(win_id, param, args)
            return args, kwargs

        for i, param in enumerate(signature.parameters.values()):
            if i == 0 and self._instance is not None:
                # Special case for 'self'.
                self._get_self_arg(win_id, param, args)
                continue
            elif param.name == self.special_params['count']:
                # Special case for count parameter.
                self._get_count_arg(param, args, kwargs)
                continue
            elif param.name == self.special_params['win_id']:
                # Special case for win_id parameter.
                self._get_win_id_arg(win_id, param, args, kwargs)
                continue
            name, value = self._get_param_name_and_value(param)
            if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
                args.append(value)
            elif param.kind == inspect.Parameter.VAR_POSITIONAL:
                if value is not None:
                    args += value
            elif param.kind == inspect.Parameter.KEYWORD_ONLY:
                kwargs[name] = value
            else:
                raise TypeError("{}: Invalid parameter type {} for argument "
                                "'{}'!".format(
                                    self.name, param.kind, param.name))
        return args, kwargs

    def run(self, win_id, args=None, count=None):
        """Run the command.

        Note we don't catch CommandError here as it might happen async.

        Args:
            win_id: The window ID the command is run in.
            args: Arguments to the command.
            count: Command repetition count.
        """
        dbgout = ["command called:", self.name]
        if args:
            dbgout.append(str(args))
        if count is not None:
            dbgout.append("(count={})".format(count))
        log.commands.debug(' '.join(dbgout))
        try:
            self.namespace = self.parser.parse_args(args)
        except argparser.ArgumentParserError as e:
            message.error(win_id, '{}: {}'.format(self.name, e))
            return
        except argparser.ArgumentParserExit as e:
            log.commands.debug("argparser exited with status {}: {}".format(
                e.status, e))
            return
        self._count = count
        posargs, kwargs = self._get_call_args(win_id)
        self._check_prerequisites(win_id)
        log.commands.debug('Calling {}'.format(
            debug.format_call(self.handler, posargs, kwargs)))
        self.handler(*posargs, **kwargs)
