#!/usr/bin/env python3
""" Default configurations for faceswap.
    Extends out :class:`configparser.ConfigParser` functionality by checking for default
    configuration updates and returning data in it's correct format """

import logging
import os
import sys
import textwrap
from collections import OrderedDict
from configparser import ConfigParser
from importlib import import_module

from lib.utils import full_path_split

logger = logging.getLogger(__name__)  # pylint: disable=invalid-name


class FaceswapConfig():
    """ Config Items """
    def __init__(self, section, configfile=None):
        """ Init Configuration  """
        logger.debug("Initializing: %s", self.__class__.__name__)
        self.configfile = self.get_config_file(configfile)
        self.config = ConfigParser(allow_no_value=True)
        self.defaults = OrderedDict()
        self.config.optionxform = str
        self.section = section

        self.set_defaults()
        self.handle_config()
        logger.debug("Initialized: %s", self.__class__.__name__)

    @property
    def changeable_items(self):
        """ Training only.
            Return a dict of config items with their set values for items
            that can be altered after the model has been created """
        retval = dict()
        sections = [sect for sect in self.config.sections() if sect.startswith("global")]
        for sect in sections + [self.section]:
            if sect not in self.defaults:
                continue
            for key, val in self.defaults[sect].items():
                if key == "helptext" or val["fixed"]:
                    continue
                retval[key] = self.get(sect, key)
        logger.debug("Alterable for existing models: %s", retval)
        return retval

    def set_defaults(self):
        """ Override for plugin specific config defaults

            Should be a series of self.add_section() and self.add_item() calls

            e.g:

            section = "sect_1"
            self.add_section(title=section,
                         info="Section 1 Information")

            self.add_item(section=section,
                          title="option_1",
                          datatype=bool,
                          default=False,
                          info="sect_1 option_1 information")
        """
        raise NotImplementedError

    def _defaults_from_plugin(self, plugin_folder):
        """ Scan the given plugins folder for config defaults.py files and update the
        default configuration.

        Parameters
        ----------
        plugin_folder: str
            The folder to scan for plugins
        """
        for dirpath, _, filenames in os.walk(plugin_folder):
            default_files = [fname for fname in filenames if fname.endswith("_defaults.py")]
            if not default_files:
                continue
            base_path = os.path.dirname(os.path.realpath(sys.argv[0]))
            # Can't use replace as there is a bug on some Windows installs that lowers some paths
            import_path = ".".join(full_path_split(dirpath[len(base_path):])[1:])
            plugin_type = import_path.split(".")[-1]
            for filename in default_files:
                self._load_defaults_from_module(filename, import_path, plugin_type)

    def _load_defaults_from_module(self, filename, module_path, plugin_type):
        """ Load the plugin's defaults module, extract defaults and add to default configuration.

        Parameters
        ----------
        filename: str
            The filename to load the defaults from
        module_path: str
            The path to load the module from
        plugin_type: str
            The type of plugin that the defaults are being loaded for
        """
        logger.debug("Adding defaults: (filename: %s, module_path: %s, plugin_type: %s",
                     filename, module_path, plugin_type)
        module = os.path.splitext(filename)[0]
        section = ".".join((plugin_type, module.replace("_defaults", "")))
        logger.debug("Importing defaults module: %s.%s", module_path, module)
        mod = import_module("{}.{}".format(module_path, module))
        self.add_section(title=section, info=mod._HELPTEXT)  # pylint:disable=protected-access
        for key, val in mod._DEFAULTS.items():  # pylint:disable=protected-access
            self.add_item(section=section, title=key, **val)
        logger.debug("Added defaults: %s", section)

    @property
    def config_dict(self):
        """ Collate global options and requested section into a dictionary with the correct
        data types """
        conf = dict()
        sections = [sect for sect in self.config.sections() if sect.startswith("global")]
        sections.append(self.section)
        for sect in sections:
            if sect not in self.config.sections():
                continue
            for key in self.config[sect]:
                if key.startswith(("#", "\n")):  # Skip comments
                    continue
                conf[key] = self.get(sect, key)
        return conf

    def get(self, section, option):
        """ Return a config item in it's correct format.

        Parameters
        ----------
        section: str
            The configuration section currently being processed
        option: str
            The configuration option currently being processed

        Returns
        -------
        varies
            The selected configuration option in the correct data format
        """
        logger.debug("Getting config item: (section: '%s', option: '%s')", section, option)
        datatype = self.defaults[section][option]["type"]
        if datatype == bool:
            func = self.config.getboolean
        elif datatype == int:
            func = self.config.getint
        elif datatype == float:
            func = self.config.getfloat
        elif datatype == list:
            func = self._parse_list
        else:
            func = self.config.get
        retval = func(section, option)
        if isinstance(retval, str) and retval.lower() == "none":
            retval = None
        logger.debug("Returning item: (type: %s, value: %s)", datatype, retval)
        return retval

    def _parse_list(self, section, option):
        """ Parse options that are stored as lists in the config file. These can be space or
        comma-separated items in the config file. They will be returned as a list of strings,
        regardless of what the final data type should be, so conversion from strings to other
        formats should be done explicitly within the retrieving code.

        Parameters
        ----------
        section: str
            The configuration section currently being processed
        option: str
            The configuration option currently being processed

        Returns
        -------
        list
            List of `str` selected items for the config choice.
        """
        raw_option = self.config.get(section, option)
        if not raw_option:
            logger.debug("No options selected, returning empty list")
            return []
        delimiter = "," if "," in raw_option else None
        retval = [opt.strip().lower() for opt in raw_option.split(delimiter)]
        logger.debug("Processed raw option '%s' to list %s for section '%s', option '%s'",
                     raw_option, retval, section, option)
        return retval

    def get_config_file(self, configfile):
        """ Return the config file from the calling folder or the provided file """
        if configfile is not None:
            if not os.path.isfile(configfile):
                err = "Config file does not exist at: {}".format(configfile)
                logger.error(err)
                raise ValueError(err)
            return configfile
        dirname = os.path.dirname(sys.modules[self.__module__].__file__)
        folder, fname = os.path.split(dirname)
        retval = os.path.join(os.path.dirname(folder), "config", "{}.ini".format(fname))
        logger.debug("Config File location: '%s'", retval)
        return retval

    def add_section(self, title=None, info=None):
        """ Add a default section to config file """
        logger.debug("Add section: (title: '%s', info: '%s')", title, info)
        if None in (title, info):
            raise ValueError("Default config sections must have a title and "
                             "information text")
        self.defaults[title] = OrderedDict()
        self.defaults[title]["helptext"] = info

    def add_item(self, section=None, title=None, datatype=str, default=None, info=None,
                 rounding=None, min_max=None, choices=None, gui_radio=False, fixed=True,
                 group=None):
        """ Add a default item to a config section

            For int or float values, rounding and min_max must be set
            This is for the slider in the GUI. The min/max values are not enforced:
            rounding:   sets the decimal places for floats or the step interval for ints.
            min_max:    tuple of min and max accepted values

            For str values choices can be set to validate input and create a combo box
            in the GUI

            For list values, choices must be provided, and a multi-option select box will
            be created

            is_radio is to indicate to the GUI that it should display Radio Buttons rather than
            combo boxes for multiple choice options.

            The 'fixed' parameter is only for training configurations. Training configurations
            are set when the model is created, and then reloaded from the state file.
            Marking an item as fixed=False indicates that this value can be changed for
            existing models, and will override the value saved in the state file with the
            updated value in config.

            The 'Group' parameter allows you to assign the config item to a group in the GUI

        """
        logger.debug("Add item: (section: '%s', title: '%s', datatype: '%s', default: '%s', "
                     "info: '%s', rounding: '%s', min_max: %s, choices: %s, gui_radio: %s, "
                     "fixed: %s, group: %s)", section, title, datatype, default, info, rounding,
                     min_max, choices, gui_radio, fixed, group)

        choices = list() if not choices else choices

        if None in (section, title, default, info):
            raise ValueError("Default config items must have a section, title, defult and "
                             "information text")
        if not self.defaults.get(section, None):
            raise ValueError("Section does not exist: {}".format(section))
        if datatype not in (str, bool, float, int, list):
            raise ValueError("'datatype' must be one of str, bool, float or "
                             "int: {} - {}".format(section, title))
        if datatype in (float, int) and (rounding is None or min_max is None):
            raise ValueError("'rounding' and 'min_max' must be set for numerical options")
        if isinstance(datatype, list) and not choices:
            raise ValueError("'choices' must be defined for list based configuration items")
        if not isinstance(choices, (list, tuple)):
            raise ValueError("'choices' must be a list or tuple")

        info = self.expand_helptext(info, choices, default, datatype, min_max, fixed)
        self.defaults[section][title] = {"default": default,
                                         "helptext": info,
                                         "type": datatype,
                                         "rounding": rounding,
                                         "min_max": min_max,
                                         "choices": choices,
                                         "gui_radio": gui_radio,
                                         "fixed": fixed,
                                         "group": group}

    @staticmethod
    def expand_helptext(helptext, choices, default, datatype, min_max, fixed):
        """ Add extra helptext info from parameters """
        helptext += "\n"
        if not fixed:
            helptext += "\nThis option can be updated for existing models.\n"
        if datatype == list:
            helptext += ("\nIf selecting multiple options then each option should be separated "
                         "by a space or a comma (e.g. item1, item2, item3)\n")
        if choices:
            helptext += "\nChoose from: {}".format(choices)
        elif datatype == bool:
            helptext += "\nChoose from: True, False"
        elif datatype == int:
            cmin, cmax = min_max
            helptext += "\nSelect an integer between {} and {}".format(cmin, cmax)
        elif datatype == float:
            cmin, cmax = min_max
            helptext += "\nSelect a decimal number between {} and {}".format(cmin, cmax)
        helptext += "\n[Default: {}]".format(default)
        return helptext

    def check_exists(self):
        """ Check that a config file exists """
        if not os.path.isfile(self.configfile):
            logger.debug("Config file does not exist: '%s'", self.configfile)
            return False
        logger.debug("Config file exists: '%s'", self.configfile)
        return True

    def create_default(self):
        """ Generate a default config if it does not exist """
        logger.debug("Creating default Config")
        for section, items in self.defaults.items():
            logger.debug("Adding section: '%s')", section)
            self.insert_config_section(section, items["helptext"])
            for item, opt in items.items():
                logger.debug("Adding option: (item: '%s', opt: '%s')", item, opt)
                if item == "helptext":
                    continue
                self.insert_config_item(section,
                                        item,
                                        opt["default"],
                                        opt)
        self.save_config()

    def insert_config_section(self, section, helptext, config=None):
        """ Insert a section into the config """
        logger.debug("Inserting section: (section: '%s', helptext: '%s', config: '%s')",
                     section, helptext, config)
        config = self.config if config is None else config
        config.optionxform = str
        helptext = self.format_help(helptext, is_section=True)
        config.add_section(section)
        config.set(section, helptext)
        logger.debug("Inserted section: '%s'", section)

    def insert_config_item(self, section, item, default, option,
                           config=None):
        """ Insert an item into a config section """
        logger.debug("Inserting item: (section: '%s', item: '%s', default: '%s', helptext: '%s', "
                     "config: '%s')", section, item, default, option["helptext"], config)
        config = self.config if config is None else config
        config.optionxform = str
        helptext = option["helptext"]
        helptext = self.format_help(helptext, is_section=False)
        config.set(section, helptext)
        config.set(section, item, str(default))
        logger.debug("Inserted item: '%s'", item)

    @staticmethod
    def format_help(helptext, is_section=False):
        """ Format comments for default ini file """
        logger.debug("Formatting help: (helptext: '%s', is_section: '%s')", helptext, is_section)
        formatted = ""
        for hlp in helptext.split("\n"):
            subsequent_indent = "\t\t" if hlp.startswith("\t") else ""
            hlp = f"\t- {hlp[1:].strip()}" if hlp.startswith("\t") else hlp
            formatted += textwrap.fill(hlp,
                                       100,
                                       tabsize=4,
                                       subsequent_indent=subsequent_indent) + "\n"
        helptext = '# {}'.format(formatted[:-1].replace("\n", "\n# "))  # Strip last newline
        if is_section:
            helptext = helptext.upper()
        else:
            helptext = "\n{}".format(helptext)
        logger.debug("formatted help: '%s'", helptext)
        return helptext

    def load_config(self):
        """ Load values from config """
        logger.verbose("Loading config: '%s'", self.configfile)
        self.config.read(self.configfile)

    def save_config(self):
        """ Save a config file """
        logger.info("Updating config at: '%s'", self.configfile)
        with open(self.configfile, "w") as f_cfgfile:
            self.config.write(f_cfgfile)
        logger.debug("Updated config at: '%s'", self.configfile)

    def validate_config(self):
        """ Check for options in default config against saved config
            and add/remove as appropriate """
        logger.debug("Validating config")
        if self.check_config_change():
            self.add_new_config_items()
        self.check_config_choices()
        logger.debug("Validated config")

    def add_new_config_items(self):
        """ Add new items to the config file """
        logger.debug("Updating config")
        new_config = ConfigParser(allow_no_value=True)
        for section, items in self.defaults.items():
            self.insert_config_section(section, items["helptext"], new_config)
            for item, opt in items.items():
                if item == "helptext":
                    continue
                if section not in self.config.sections():
                    logger.debug("Adding new config section: '%s'", section)
                    opt_value = opt["default"]
                else:
                    opt_value = self.config[section].get(item, opt["default"])
                self.insert_config_item(section,
                                        item,
                                        opt_value,
                                        opt,
                                        new_config)
        self.config = new_config
        self.config.optionxform = str
        self.save_config()
        logger.debug("Updated config")

    def check_config_choices(self):
        """ Check that config items are valid choices """
        logger.debug("Checking config choices")
        for section, items in self.defaults.items():
            for item, opt in items.items():
                if item == "helptext" or not opt["choices"]:
                    continue
                if opt["type"] == list:  # Multi-select items
                    opt_value = self._parse_list(section, item)
                    if not opt_value:  # No option selected
                        continue
                    if not all(val in opt["choices"] for val in opt_value):
                        invalid = [val for val in opt_value if val not in opt["choices"]]
                        valid = ", ".join(val for val in opt_value if val in opt["choices"])
                        logger.warning("The option(s) %s are not valid selections for '%s': '%s'. "
                                       "setting to: '%s'", invalid, section, item, valid)
                        self.config.set(section, item, valid)
                else:  # Single-select items
                    opt_value = self.config.get(section, item)
                    if opt_value.lower() == "none" and any(choice.lower() == "none"
                                                           for choice in opt["choices"]):
                        continue
                    if opt_value not in opt["choices"]:
                        default = str(opt["default"])
                        logger.warning("'%s' is not a valid config choice for '%s': '%s'. "
                                       "Defaulting to: '%s'", opt_value, section, item, default)
                        self.config.set(section, item, default)
        logger.debug("Checked config choices")

    def check_config_change(self):
        """ Check whether new default items have been added or removed
            from the config file compared to saved version """
        if set(self.config.sections()) != set(self.defaults.keys()):
            logger.debug("Default config has new section(s)")
            return True

        for section, items in self.defaults.items():
            opts = [opt for opt in items.keys() if opt != "helptext"]
            exists = [opt for opt in self.config[section].keys()
                      if not opt.startswith(("# ", "\n# "))]
            if set(exists) != set(opts):
                logger.debug("Default config has new item(s)")
                return True
        logger.debug("Default config has not changed")
        return False

    def handle_config(self):
        """ Handle the config.

        Checks whether a config file exists for this section. If not then a default is created.

        Configuration choices are then loaded and validated
        """
        logger.debug("Handling config: (section: %s, configfile: '%s')",
                     self.section, self.configfile)
        if not self.check_exists():
            self.create_default()
        self.load_config()
        self.validate_config()
        logger.debug("Handled config")


def generate_configs():
    """ Generate config files if they don't exist.

    This script is run prior to anything being set up, so don't use logging
    Generates the default config files for plugins in the faceswap config folder
    """

    base_path = os.path.realpath(os.path.dirname(sys.argv[0]))
    plugins_path = os.path.join(base_path, "plugins")
    configs_path = os.path.join(base_path, "config")
    for dirpath, _, filenames in os.walk(plugins_path):
        if "_config.py" in filenames:
            section = os.path.split(dirpath)[-1]
            config_file = os.path.join(configs_path, "{}.ini".format(section))
            if not os.path.exists(config_file):
                mod = import_module("plugins.{}.{}".format(section, "_config"))
                mod.Config(None)
