# Copyright 2016-2020 Swiss National Supercomputing Centre (CSCS/ETH Zurich)
# ReFrame Project Developers. See the top-level LICENSE file for details.
#
# SPDX-License-Identifier: BSD-3-Clause

import argparse
import os

#
# Notes on the ArgumentParser design
#
# An obvious design for the Reframe's `ArgumentParser` would be to directly
# inherit from `argparse.ArgumentParser`. However, this would not allow us to
# intercept the call to `add_argument()` of an argument group. Argument groups
# are of an "unknown" type to the users of the `argparse` module, since they
# inherit from an internal private class.
#
# For this reason, we base our design on composition by implementing wrappers
# of both the argument group and the argument parser. These wrappers provide
# the same public interface as their `argparse` counterparts (currently we only
# implement the part of the interface that matters for ReFrame), delegating the
# parsing work to them. For these "shadow" data structures for argument groups
# and the parser, we follow a similar design as in the `argparse` module: both
# the argument group and the parser inherit from a base class implementing the
# functionality of `add_argument()`.
#
# A final trick we had to do in order to avoid repeating all the public fields
# of the internal argument holders (`argparse`'s argument group or argument
# parser) was to programmaticallly export them by implementing the
# `__getattr__()` method, such as to delegate any lookup of unknown public
# attributes to the underlying `argparse.ArgumentParser`.
#
# Finally, the functionality of the ArgumentParser is extended to support
# associations of command-line arguments with environment variables and/or
# configuration parameters. Additionally, we allow to define pseudo-arguments
# that essentially associate environment variables with configuration
# arguments, without having to define a corresponding command line option.


def _convert_to_bool(s):
    if s.lower() in ('true', 'yes', 'y'):
        return True

    if s.lower() in ('false', 'no', 'n'):
        return False

    raise ValueError


class _Namespace:
    def __init__(self, namespace, option_map):
        self.__namespace = namespace
        self.__option_map = option_map

    @property
    def cmd_options(self):
        '''Options filled in by command-line'''
        return self.__namespace

    @property
    def env_vars(self):
        '''Environment variables related to ReFrame'''
        return [v[0].split()[0] for v in self.__option_map.values() if v[0]]

    def __getattr__(self, name):
        if name.startswith('_'):
            raise AttributeError(
                f"'{type(self).__name__}' object has no attribute '{name}'"
            )

        try:
            ret = getattr(self.__namespace, name)
        except AttributeError:
            if name not in self.__option_map:
                # Option not defined at all
                raise

            # Option is not associated with a command-line argument
            ret = None

        if name not in self.__option_map:
            return ret

        envvar, _, action = self.__option_map[name]
        if ret is None and envvar is not None:
            # Try the environment variable
            envvar, *delim = envvar.split(maxsplit=2)
            delim = delim[0] if delim else ','
            ret = os.getenv(envvar)
            if ret is not None:
                if action.startswith('append'):
                    # The option should be interpreted as comma separated list
                    ret = ret.split(delim)
                elif action in ('store_true', 'store_false'):
                    try:
                        ret = _convert_to_bool(ret)
                    except ValueError:
                        raise ValueError(
                            f'environment variable {envvar!r} not a boolean'
                        ) from None

        return ret

    def update_config(self, site_config):
        '''Update the site configuration with the options represented by this
        namespace'''
        errors = []
        for option, spec in self.__option_map.items():
            _, confvar, action = spec
            if action == 'version' or confvar is None:
                continue

            try:
                value = getattr(self, option)
            except ValueError as e:
                errors.append(e)
                continue

            if value is not None:
                site_config.add_sticky_option(confvar, value)

        return errors

    def __repr__(self):
        return (f'{type(self).__name__}({self.__namespace!r}, '
                '{self.__option_map})')


class _ArgumentHolder:
    def __init__(self, holder, shared_options=None):
        self._holder = holder
        self._defaults = argparse.Namespace()

        # Map command-line options to environment variables and configuration
        # options. Values are tuples of the form (envvar, configvar)
        self._option_map = shared_options if shared_options is not None else {}

    def __getattr__(self, name):
        # Delegate all unknown public attribute requests to the underlying
        # holder
        if name.startswith('_'):
            raise AttributeError(
                f"'{type(self).__name__}' object has no attribute '{name}'"
            )

        return getattr(self._holder, name)

    def add_argument(self, *flags, **kwargs):
        try:
            opt_name = kwargs['dest']
        except KeyError:
            # Try to figure out the dest name as the original ArgumentParser
            opt_name = None
            for f in flags:
                # The last long option is taken into account as the option
                # name
                if f.startswith('--'):
                    opt_name = f[2:].replace('-', '_')

            if flags and opt_name is None:
                # The first short option is taken into account as the
                # option name
                if flags[0].startswith('-'):
                    opt_name = flags[0][1:].replace('-', '_')

            if flags and opt_name is None:
                # A positional argument
                opt_name = flags[-1]

        if opt_name is None:
            raise ValueError('could not infer a dest name: no flags defined')

        self._option_map[opt_name] = (
            kwargs.get('envvar', None),
            kwargs.get('configvar', None),
            kwargs.get('action', 'store')
        )
        # Remove envvar and configvar keyword arguments and force dest
        # argument, even if we guessed it, in order to guard against changes
        # in ArgumentParser's implementation
        kwargs.pop('envvar', None)
        kwargs.pop('configvar', None)
        kwargs['dest'] = opt_name

        # Convert 'store_true' and 'store_false' actions to their
        # 'store_const' equivalents, because they otherwise imply imply a
        # default
        action = kwargs.get('action', None)
        if action == 'store_true' or action == 'store_false':
            kwargs['action'] = 'store_const'
            kwargs['const'] = True  if action == 'store_true'  else False
            kwargs['const'] = False if action == 'store_false' else True

        # Remove defaults
        try:
            self._defaults.__dict__[opt_name] = kwargs['default']
            del kwargs['default']
        except KeyError:
            self._defaults.__dict__[opt_name] = None

        if not flags:
            return None

        return self._holder.add_argument(*flags, **kwargs)


class _ArgumentGroup(_ArgumentHolder):
    pass


class ArgumentParser(_ArgumentHolder):
    '''Reframe's extended argument parser.

    This argument parser behaves almost identical to the original
    `argparse.ArgumenParser`. In fact, it uses such a parser internally,
    delegating all the calls to it. The key difference is how newly parsed
    options are combined with existing namespaces in `parse_args()`.'''

    def __init__(self, **kwargs):
        super().__init__(argparse.ArgumentParser(**kwargs))
        self._groups = []

    def add_argument_group(self, *args, **kwargs):
        group = _ArgumentGroup(
            self._holder.add_argument_group(*args, **kwargs),
            self._option_map
        )
        self._groups.append(group)
        return group

    def _resolve_attr(self, attr, namespaces):
        for ns in namespaces:
            if ns is None:
                continue

            val = ns.__dict__.setdefault(attr, None)
            if val is not None:
                return val

        return None

    def _update_defaults(self):
        for g in self._groups:
            self._defaults.__dict__.update(g._defaults.__dict__)

    def parse_args(self, args=None, namespace=None):
        '''Convert argument strings to objects and return them as attributes of
        a namespace.

        If `namespace` is `None`, this method is equivalent to
        `argparse.ArgumentParser.parse_args()`.

        If `namespace` is not `None` and an attribute has not been assigned a
        value during the parsing process of argument strings `args`, a value
        for it will be looked up first in `namespace` and if not found there,
        it will be assigned the default value as specified in its corresponding
        `add_argument()` call. If no default value was specified either, the
        attribute will be set to `None`.'''

        # We always pass an empty namespace to our internal argparser and we do
        # the namespace resolution ourselves. We do this, because we want the
        # newly parsed options to completely override any options defined in
        # namespace. The implementation of `argparse.ArgumentParser` does not
        # do this in options with an 'append' action.
        options = self._holder.parse_args(args, None)

        # Check if namespace refers to our namespace and take the cmd options
        # namespace suitable for ArgumentParser
        if isinstance(namespace, _Namespace):
            namespace = namespace.cmd_options

        # Update parser's defaults with groups' defaults
        self._update_defaults()
        for attr, val in options.__dict__.items():
            if val is None:
                options.__dict__[attr] = self._resolve_attr(
                    attr, [namespace, self._defaults]
                )

        return _Namespace(options, self._option_map)
