# SPDX-License-Identifier: BSD-2-Clause

# Copyright (c) 2024 Phil Thompson <phil@riverbankcomputing.com>


from packaging.markers import Marker

from .exceptions import UserException
from .pyproject import PyProjectOptionException


class Configurable:
    """ A base class for an object that can be configured by a pyproject.toml,
    a build script or (possibly) the user via command line options.
    """

    def add_command_line_options(self, parser, tool, all_options, options=None):
        """ Add the object's command line options to an argument parser and
        update a map of Object instances and the configurables that they should
        be applied to.
        """

        if options is None:
            options = self.get_options()

        for option in options:
            # If it has already been set explicitly then the user cannot change
            # it.
            if getattr(self, option.name) is not None:
                continue

            # If there is no help then the user can never specify it.
            if option.help is None:
                continue

            # See if the option is applicable to this tool.
            if option.tools and tool not in option.tools:
                continue

            # See if the option has already been added.
            configurables = all_options.setdefault(option, list())
            configurables.append(self)

            if len(configurables) != 1:
                continue

            # Add the option according to its type.
            argument_name = option.user_name

            if option.inverted:
                argument_name = 'no-' + argument_name

            argument_name = '--' + argument_name

            if option.option_type is bool:
                parser.add_argument(argument_name, dest=option.dest,
                        action=('store_false' if option.inverted
                                else 'store_true'),
                        help=option.help)
            elif option.option_type is list:
                # Remove any plural.
                if argument_name.endswith('s'):
                    argument_name = argument_name[:-1]

                parser.add_argument(argument_name, dest=option.dest,
                        choices=option.choices, action='append',
                        help=option.help, metavar=option.metavar)
            else:
                parser.add_argument(argument_name, dest=option.dest,
                        type=option.option_type, choices=option.choices,
                        help=option.help, metavar=option.metavar)

    def apply_nonuser_defaults(self, tool):
        """ Set default values for each non-user configurable option that
        hasn't been set yet.
        """

        self._apply_defaults(tool, user=False)

    def apply_user_defaults(self, tool):
        """ Set default values for each user configurable option that hasn't
        been set yet.
        """

        self._apply_defaults(tool, user=True)

    def configure(self, pyproject, section_name, tool):
        """ Perform the initial configuration of an object. """

        section = pyproject.get_section(section_name)

        if section is not None:
            for name, value in section.items():
                # Find the corresponding option.
                for option in self.get_options():
                    if option.user_name == name:
                        break
                else:
                    raise PyProjectOptionException(name,
                            "is not a supported option",
                            section_name=section_name)

                # Check the type of the option.
                if not isinstance(value, option.option_type):
                    raise PyProjectOptionException(name,
                            "should be of type '{0}' and not '{1}'".format(
                                    option.option_type.__name__,
                                    type(value).__name__),
                            section_name=section_name)

                # Check the option hasn't already been initialised.
                if getattr(self, option.name) is not None:
                    raise PyProjectOptionException(name,
                            "has already been set in code and cannot be "
                            "changed",
                            section_name=section_name)

                # Evaluate any environment markers if the option supports them.
                if isinstance(value, list):
                    new_value = []

                    for v in value:
                        v = self._handle_marker(v, name, section_name)
                        if v is not None:
                            new_value.append(v)

                    value = new_value

                setattr(self, option.name, value)

        self.apply_nonuser_defaults(tool)

    def get_options(self):
        """ Return a list of configurable options. """

        return list()

    def initialise_options(self, kwargs):
        """ Initialise the options. """

        # Set the value for each option from the keyword arguments or undefined
        # if not specified.
        names = []

        for option in self.get_options():
            name = option.name
            names.append(name)

            setattr(self, name, kwargs.get(name))

        # Check that all keyword arguments are valid options.
        for kw in kwargs.keys():
            if kw not in names:
                raise UserException("'{0}' is not a valid option".format(kw))

    def verify_configuration(self, tool):
        """ Verify that the configuration is complete and consistent. """

        # This default implementation does nothing.

    def _apply_defaults(self, tool, user):
        """ Set default values for each user/non-user option that hasn't been
        set yet.
        """

        for option in self.get_options():
            if user and option.help is None:
                continue

            if not user and option.help is not None:
                continue

            value = getattr(self, option.name)
            if value is None:
                value = option.default
                if value is None:
                    # Provide a default default based on the type.
                    if option.option_type is list:
                        value = []
                    elif option.option_type is float:
                        value = 0.0
                    elif option.option_type is int:
                        value = 0
                    elif option.option_type is bool:
                        value = True if option.inverted else False
                    elif option.option_type is str:
                        value = ''
                else:
                    # Make a copy of the default in case it is mutable.
                    value = option.option_type(value)

                setattr(self, option.name, value)

    @staticmethod
    def _handle_marker(value, name, section_name):
        """ Handle any environment marker in a value.  The value is returned if
        a marker evaluates to True.  None is returned if a marker evaluates to
        False.
        """

        # Handle the trivial case of there being no marker.
        if ';' not in value:
            return value

        value, marker = value.split(';', maxsplit=1)

        try:
            satisfied = Marker(marker).evaluate()
        except:
            raise PyProjectOptionException(name,
                    "has an invalid marker '{0}'".format(marker),
                    section_name=section_name)

        return value if satisfied else None


class Option:
    """ Encapsulate a configuration option.  This defines and implements an
    attribute of a Configurable object.  The value of the attribute can be set
    either by __init__(), the pyproject.toml file and by the user using a
    command line argument (in that order).  Once the value is set it cannot be
    changed subsequently.  For example, if an attribute is set in
    pyproject.toml then the user will not then be able to modify it from the
    command line.  The value can only be changed from the command line if the
    Option object has help text specified.
    """

    # The tools that will build a set of bindings.
    BUILD_TOOLS = ('build', 'install', 'wheel')

    # All the valid tools.
    _ALL_TOOLS = BUILD_TOOLS + ('sdist', )

    # This is used to make sure each option (even if they are handling the same
    # attribute) has a unique 'dest'.
    option_nr = 0

    def __init__(self, name, *, option_type=str, choices=None, default=None,
            help=None, metavar=None, inverted=False, tools=None):
        """ Initialise the option. """

        self.name = name
        self.user_name = name.replace('_', '-')
        self.option_type = option_type
        self.default = default
        self.choices = choices
        self.help = help
        self.metavar = metavar
        self.inverted = inverted

        if tools is None:
            self.tools = self.BUILD_TOOLS
        else:
            for tool in tools:
                if tool not in self._ALL_TOOLS:
                    raise UserException(
                            "'{0}' option has an invalid tools '{1}'".format(
                                    name, tool))

            self.tools = tools

        self.dest = 'd' + str(type(self).option_nr)
        type(self).option_nr += 1
