"""Option handling and Option management logic."""
import collections
import logging
import optparse  # pylint: disable=deprecated-module
from typing import Any, Callable, Dict, List, Optional, Set

from flake8 import utils

LOG = logging.getLogger(__name__)


class Option(object):
    """Our wrapper around an optparse.Option object to add features."""

    def __init__(
        self,
        short_option_name=None,
        long_option_name=None,
        # Options below here are taken from the optparse.Option class
        action=None,
        default=None,
        type=None,
        dest=None,
        nargs=None,
        const=None,
        choices=None,
        callback=None,
        callback_args=None,
        callback_kwargs=None,
        help=None,
        metavar=None,
        # Options below here are specific to Flake8
        parse_from_config=False,
        comma_separated_list=False,
        normalize_paths=False,
    ):
        """Initialize an Option instance wrapping optparse.Option.

        The following are all passed directly through to optparse.

        :param str short_option_name:
            The short name of the option (e.g., ``-x``). This will be the
            first argument passed to :class:`~optparse.Option`.
        :param str long_option_name:
            The long name of the option (e.g., ``--xtra-long-option``). This
            will be the second argument passed to :class:`~optparse.Option`.
        :param str action:
            Any action allowed by :mod:`optparse`.
        :param default:
            Default value of the option.
        :param type:
            Any type allowed by :mod:`optparse`.
        :param dest:
            Attribute name to store parsed option value as.
        :param nargs:
            Number of arguments to parse for this option.
        :param const:
            Constant value to store on a common destination. Usually used in
            conjuntion with ``action="store_const"``.
        :param iterable choices:
            Possible values for the option.
        :param callable callback:
            Callback used if the action is ``"callback"``.
        :param iterable callback_args:
            Additional positional arguments to the callback callable.
        :param dictionary callback_kwargs:
            Keyword arguments to the callback callable.
        :param str help:
            Help text displayed in the usage information.
        :param str metavar:
            Name to use instead of the long option name for help text.

        The following parameters are for Flake8's option handling alone.

        :param bool parse_from_config:
            Whether or not this option should be parsed out of config files.
        :param bool comma_separated_list:
            Whether the option is a comma separated list when parsing from a
            config file.
        :param bool normalize_paths:
            Whether the option is expecting a path or list of paths and should
            attempt to normalize the paths to absolute paths.
        """
        self.short_option_name = short_option_name
        self.long_option_name = long_option_name
        self.option_args = [
            x for x in (short_option_name, long_option_name) if x is not None
        ]
        self.option_kwargs = {
            "action": action,
            "default": default,
            "type": type,
            "dest": self._make_dest(dest),
            "nargs": nargs,
            "const": const,
            "choices": choices,
            "callback": callback,
            "callback_args": callback_args,
            "callback_kwargs": callback_kwargs,
            "help": help,
            "metavar": metavar,
        }
        # Set attributes for our option arguments
        for key, value in self.option_kwargs.items():
            setattr(self, key, value)

        # Set our custom attributes
        self.parse_from_config = parse_from_config
        self.comma_separated_list = comma_separated_list
        self.normalize_paths = normalize_paths

        self.config_name = None  # type: Optional[str]
        if parse_from_config:
            if not long_option_name:
                raise ValueError(
                    "When specifying parse_from_config=True, "
                    "a long_option_name must also be specified."
                )
            self.config_name = long_option_name[2:].replace("-", "_")

        self._opt = None

    def __repr__(self):  # noqa: D105
        return (
            "Option({0}, {1}, action={action}, default={default}, "
            "dest={dest}, type={type}, callback={callback}, help={help},"
            " callback={callback}, callback_args={callback_args}, "
            "callback_kwargs={callback_kwargs}, metavar={metavar})"
        ).format(
            self.short_option_name,
            self.long_option_name,
            **self.option_kwargs
        )

    def _make_dest(self, dest):
        if dest:
            return dest

        if self.long_option_name:
            return self.long_option_name[2:].replace("-", "_")
        return self.short_option_name[1]

    def normalize(self, value, *normalize_args):
        """Normalize the value based on the option configuration."""
        if self.normalize_paths:
            # Decide whether to parse a list of paths or a single path
            normalize = utils.normalize_path  # type: Callable[..., Any]
            if self.comma_separated_list:
                normalize = utils.normalize_paths
            return normalize(value, *normalize_args)
        elif self.comma_separated_list:
            return utils.parse_comma_separated_list(value)
        return value

    def normalize_from_setuptools(self, value):
        """Normalize the value received from setuptools."""
        value = self.normalize(value)
        if self.type == "int" or self.action == "count":
            return int(value)
        elif self.type == "float":
            return float(value)
        elif self.type == "complex":
            return complex(value)
        if self.action in ("store_true", "store_false"):
            value = str(value).upper()
            if value in ("1", "T", "TRUE", "ON"):
                return True
            if value in ("0", "F", "FALSE", "OFF"):
                return False
        return value

    def to_optparse(self):
        """Convert a Flake8 Option to an optparse Option."""
        if self._opt is None:
            self._opt = optparse.Option(
                *self.option_args, **self.option_kwargs
            )
        return self._opt


PluginVersion = collections.namedtuple(
    "PluginVersion", ["name", "version", "local"]
)


class OptionManager(object):
    """Manage Options and OptionParser while adding post-processing."""

    def __init__(
        self, prog=None, version=None, usage="%prog [options] file file ..."
    ):
        """Initialize an instance of an OptionManager.

        :param str prog:
            Name of the actual program (e.g., flake8).
        :param str version:
            Version string for the program.
        :param str usage:
            Basic usage string used by the OptionParser.
        """
        self.parser = optparse.OptionParser(
            prog=prog, version=version, usage=usage
        )
        self.config_options_dict = {}  # type: Dict[str, Option]
        self.options = []  # type: List[Option]
        self.program_name = prog
        self.version = version
        self.registered_plugins = set()  # type: Set[PluginVersion]
        self.extended_default_ignore = set()  # type: Set[str]
        self.extended_default_select = set()  # type: Set[str]

    @staticmethod
    def format_plugin(plugin):
        """Convert a PluginVersion into a dictionary mapping name to value."""
        return {attr: getattr(plugin, attr) for attr in ["name", "version"]}

    def add_option(self, *args, **kwargs):
        """Create and register a new option.

        See parameters for :class:`~flake8.options.manager.Option` for
        acceptable arguments to this method.

        .. note::

            ``short_option_name`` and ``long_option_name`` may be specified
            positionally as they are with optparse normally.
        """
        if len(args) == 1 and args[0].startswith("--"):
            args = (None, args[0])
        option = Option(*args, **kwargs)
        self.parser.add_option(option.to_optparse())
        self.options.append(option)
        if option.parse_from_config:
            name = option.config_name
            assert name is not None  # nosec (for mypy)
            self.config_options_dict[name] = option
            self.config_options_dict[name.replace("_", "-")] = option
        LOG.debug('Registered option "%s".', option)

    def remove_from_default_ignore(self, error_codes):
        """Remove specified error codes from the default ignore list.

        :param list error_codes:
            List of strings that are the error/warning codes to attempt to
            remove from the extended default ignore list.
        """
        LOG.debug("Removing %r from the default ignore list", error_codes)
        for error_code in error_codes:
            try:
                self.extended_default_ignore.remove(error_code)
            except (ValueError, KeyError):
                LOG.debug(
                    "Attempted to remove %s from default ignore"
                    " but it was not a member of the list.",
                    error_code,
                )

    def extend_default_ignore(self, error_codes):
        """Extend the default ignore list with the error codes provided.

        :param list error_codes:
            List of strings that are the error/warning codes with which to
            extend the default ignore list.
        """
        LOG.debug("Extending default ignore list with %r", error_codes)
        self.extended_default_ignore.update(error_codes)

    def extend_default_select(self, error_codes):
        """Extend the default select list with the error codes provided.

        :param list error_codes:
            List of strings that are the error/warning codes with which
            to extend the default select list.
        """
        LOG.debug("Extending default select list with %r", error_codes)
        self.extended_default_select.update(error_codes)

    def generate_versions(
        self, format_str="%(name)s: %(version)s", join_on=", "
    ):
        """Generate a comma-separated list of versions of plugins."""
        return join_on.join(
            format_str % self.format_plugin(plugin)
            for plugin in sorted(self.registered_plugins)
        )

    def update_version_string(self):
        """Update the flake8 version string."""
        self.parser.version = (
            self.version
            + " ("
            + self.generate_versions()
            + ") "
            + utils.get_python_version()
        )

    def generate_epilog(self):
        """Create an epilog with the version and name of each of plugin."""
        plugin_version_format = "%(name)s: %(version)s"
        self.parser.epilog = "Installed plugins: " + self.generate_versions(
            plugin_version_format
        )

    def _normalize(self, options):
        for option in self.options:
            old_value = getattr(options, option.dest)
            setattr(options, option.dest, option.normalize(old_value))

    def parse_args(self, args=None, values=None):
        """Proxy to calling the OptionParser's parse_args method."""
        self.generate_epilog()
        self.update_version_string()
        options, xargs = self.parser.parse_args(args, values)
        self._normalize(options)
        return options, xargs

    def parse_known_args(self, args=None, values=None):
        """Parse only the known arguments from the argument values.

        Replicate a little argparse behaviour while we're still on
        optparse.
        """
        self.generate_epilog()
        self.update_version_string()
        # Taken from optparse.OptionParser.parse_args
        rargs = self.parser._get_args(args)
        if values is None:
            values = self.parser.get_default_values()

        self.parser.rargs = rargs
        largs = []  # type: List[str]
        self.parser.values = values

        while rargs:
            # NOTE(sigmavirus24): If we only care about *known* options, then
            # we should just shift the bad option over to the largs list and
            # carry on.
            # Unfortunately, we need to rely on a private method here.
            try:
                self.parser._process_args(largs, rargs, values)
            except (
                optparse.BadOptionError,
                optparse.OptionValueError,
            ) as err:
                # TODO: https://gitlab.com/pycqa/flake8/issues/541
                largs.append(err.opt_str)  # type: ignore

        args = largs + rargs
        options, xargs = self.parser.check_values(values, args)
        self._normalize(options)
        return options, xargs

    def register_plugin(self, name, version, local=False):
        """Register a plugin relying on the OptionManager.

        :param str name:
            The name of the checker itself. This will be the ``name``
            attribute of the class or function loaded from the entry-point.
        :param str version:
            The version of the checker that we're using.
        :param bool local:
            Whether the plugin is local to the project/repository or not.
        """
        self.registered_plugins.add(PluginVersion(name, version, local))
