import copy
import glob
import os
import re
import sys
import typing
from abc import ABCMeta, abstractmethod
from collections import defaultdict
from json import JSONEncoder
from typing import TypeVar, Generic, Callable, \
    Optional, List, Tuple, Union, Dict, Generator, Set
import configparser

T = TypeVar('T')


class SettingAliases(object):
    def __init__(self,
                 flag: Optional[str] = None,
                 short_flag: Optional[str] = None,
                 env_variable: Optional[str] = None):
        """
        Specify aliases for a config property
        :param flag: an alternate flag name that does not
            match the schema property name
        :param short_flag: maps short-flag name for CLI parsing
        :param env_variable: maps an environment variable
            name to the property
        """
        self.flag: Optional[str] = flag
        self.short_flag: Optional[str] = short_flag
        self.env_variable: Optional[str] = env_variable

    def labels(self) -> List[str]:
        return list(filter(None,
                           [self.flag, self.short_flag, self.env_variable]))

    def __str__(self) -> str:
        parts = [
            f"<{self.__class__.__module__}.{self.__class__.__name__}",
            f"addr={hex(id(self))}"
        ]
        for k, v in self.__dict__.items():
            if v:
                parts += [f"{k}={v}"]
        parts += ["/>"]
        return ' '.join(parts)


class JsonEncoder(JSONEncoder):
    def default(self, o):
        return getattr(o, '__name__', '') \
               or getattr(o, '__dict__', '') \
               or 'unserializable'


SettingType = TypeVar('SettingType')


class Setting(Generic[T]):
    def __init__(self,
                 formatter: Callable[[str], T],
                 default: Optional[T] = None,
                 required: bool = True,
                 aliases: Optional[SettingAliases] = None):
        """
        Specify the schema for an individual configuration property.
        :param formatter: parses a string value into a parsed value of type T.
        :param default: default value if no configuration is specified
        :param required: indicates that this property is required
        """
        self.formatter: Callable[[str], T] = formatter
        self.default: Optional[T] = default
        self.required: bool = required
        self.aliases: Optional[SettingAliases] = aliases

    def from_raw_value(self, value: str) -> T:
        try:
            return self.formatter(value)
        except Exception as _:
            raise ValueError(
                f"{value} cannot be parsed as {self.formatter.__name__}"
            )

    def __str__(self) -> str:
        parts = [
            f"<{self.__class__.__module__}.{self.__class__.__name__}",
            f"addr={hex(id(self))}"
        ]
        for k, v in self.__dict__.items():
            if v:
                parts += [f"{k}={v}"]
        parts += ["/>"]
        return ' '.join(parts)

    def get(self) -> Optional[T]:
        return self.default

    def to_gettable(self, value: List[T]):
        return GettableSetting(
            value,
            self.formatter,
            self.default,
            self.required
        )


class GettableSetting(Setting[T]):
    def __init__(self,
                 value: Optional[T],
                 formatter: Callable[[str], T],
                 default: Optional[T] = None,
                 required: bool = True):
        super().__init__(
            formatter=formatter,
            default=default,
            required=required
        )
        self.value: Optional[T] = value

    def get(self) -> Optional[T]:
        return self.value


class ListSetting(Generic[T]):
    def __init__(self,
                 formatter: Callable[[str], T],
                 default: Optional[List[T]] = None,
                 required: bool = True,
                 aliases: Optional[SettingAliases] = None):
        """
        Specify the schema for an individual configuration property.
        :param formatter: parses the elements of a string value into
        :param default: default value if no configuration is specified
        :param required: indicates that this property is required
        """
        self.formatter: Callable[[str], T] = formatter
        self.default: Optional[List[T]] = default
        self.required: bool = required
        self.aliases: Optional[SettingAliases] = aliases

    def from_raw_value(self, value: str) -> List[T]:
        result: List[T] = []
        # ListSetting assumes values are CSV. Repeated command line flags
        # must be treated specially, but will also work with csv values.
        value_parts = value.split(",") if isinstance(value, str) else [value]
        for part in value_parts:
            try:
                result.append(self.formatter(part))
            except Exception as _:
                raise ValueError(
                    f"{value} cannot be parsed as {self.formatter.__name__}"
                )
        return result

    def get(self) -> Optional[List[T]]:
        return self.default

    def to_gettable(self, value: List[T]):
        return GettableListSetting(
            value,
            self.formatter,
            self.default,
            self.required
        )

    def __str__(self) -> str:
        parts = [
            f"<{self.__class__.__module__}.{self.__class__.__name__}",
            f"addr={hex(id(self))}"
        ]
        for k, v in self.__dict__.items():
            if v:
                parts += [f"{k}={v}"]
        parts += ["/>"]
        return ' '.join(parts)


class GettableListSetting(ListSetting[T]):
    def __init__(self,
                 value: Optional[List[T]],
                 formatter: Callable[[str], T],
                 default: Optional[List[T]] = None,
                 required: bool = True):
        super().__init__(
            formatter=formatter,
            default=default,
            required=required
        )
        self.value: Optional[List[T]] = value

    def get(self) -> Optional[List[T]]:
        return self.value


CliArgTuple = Tuple[str, Union[str, bool]]


def parse_cli_arguments(args: List[str]) -> \
        Tuple[List[CliArgTuple], List[str]]:
    idx = 0
    results: List[CliArgTuple] = []
    positional: List[str] = []
    while idx < len(args):
        cur = args[idx]
        if cur.startswith('-'):
            parts = cur.split('=')
            flag = parts[0].lstrip('-')
            if len(parts) == 1:
                is_boolean_flag = (idx == len(args) - 1) \
                                  or args[idx + 1].startswith('-')
                if is_boolean_flag:
                    value = "" if flag.startswith('no-') else "TRUE"
                    if not value:
                        flag = flag[3:]
                else:
                    # flag with argument, separated by space
                    value = args[idx + 1].strip()
                    idx += 1
            else:
                value = parts[1].strip()
            # translate hyphens to underscores to match
            # syntax requirements
            flag = flag.replace('-', '_')
            results.append((flag, value))
        idx += 1

    return results, positional


##################################################################
# Sanity block
#
# We _will_ be parsing multiple sources
# Each source parser will yield a mapping informed by the schema
# The schema, per Setting, will enforce precedence from the
# set of results
##################################################################

class RawSetting(object):
    def __init__(self, raw_name: str, raw_value: Union[str, bool]):
        self.raw_name: str = raw_name
        self.raw_value: Union[str, bool] = raw_value

    @staticmethod
    def merge(raw_name: str, raw_settings: List['RawSetting']) -> 'RawSetting':
        merged_value: str = ','.join([str(setting.raw_value)
                                      for setting in raw_settings])
        return RawSetting(raw_name, merged_value)


class SettingsSource(metaclass=ABCMeta):
    @abstractmethod
    def get_raw_setting(self,
                        namespace: Optional[str],
                        canonical_name: str,
                        aliases: Optional[SettingAliases],
                        as_list: bool = False) -> \
            Optional[RawSetting]:
        """
        :param namespace: namespace for config name_or_alias, typically maps to
            a SettingsDefinition class name
        :param canonical_name: a string name, sources from either
            SettingsDefinition property name.
        :param aliases: optional SettingAliases instance, specifies aliases
            from definition.
        :param as_list: whether to pull all existing matching values as a list.
        :return: RawSetting if found, else None
        """
        raise NotImplementedError()


class CLISettingsSource(SettingsSource):
    def __init__(self, args: List[str] = sys.argv):
        self.args = args
        self.raw_settings: Dict[str, List[RawSetting]] = defaultdict(list)
        for rs in self.load():
            self.raw_settings[rs.raw_name].append(rs)

    def load(self) -> List[RawSetting]:
        flag_arguments, positional = parse_cli_arguments(self.args)
        results: List[RawSetting] = []
        for flag_arg in flag_arguments:
            results.append(RawSetting(flag_arg[0], flag_arg[1]))

        return results

    def get_raw_setting(self,
                        namespace: Optional[str],
                        canonical_name: str,
                        aliases: Optional[SettingAliases],
                        as_list: bool = False) -> \
            Optional[RawSetting]:
        """
        :param namespace: namespace for config name_or_alias, typically maps to
            a SettingsDefinition class name
        :param canonical_name: a string name, sources from either
            SettingsDefinition property name.
        :param aliases: options SettingAliases instance, specifies aliases from
            definition.
        :param as_list: whether to pull all existing matching values as a list.
        :return: RawSetting if found, else None
        """
        intermediate_results: List[RawSetting] = []

        local_name = canonical_name

        if aliases and aliases.flag:
            local_name = aliases.flag

        short_flag = local_name[0]
        if aliases and aliases.short_flag:
            short_flag = aliases.short_flag

        forms = [
            local_name,  # property name
            short_flag,  # short flag
        ]

        if namespace:
            forms = [
                        f"{namespace}.{local_name}",  # fully qualified form
                    ] + forms

        for form in forms:
            candidates = self.raw_settings.get(form)
            if as_list and candidates is not None:
                intermediate_results.append(RawSetting.merge(form, candidates))
            elif candidates and len(candidates) > 0:
                intermediate_results.append(candidates[-1])

        if intermediate_results and len(intermediate_results) > 1:
            raise ValueError(f"Multiple forms of {forms[0]} used in CLI "
                             f"arguments, an illegal combination.")
        return intermediate_results[0] if intermediate_results else None


def camel_to_big_snake(name):
    name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
    return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).upper()


FlexibleEnvironType = Union[os._Environ, Dict[str, str]]


class EnvironSettingsSource(SettingsSource):
    def __init__(self, environ: FlexibleEnvironType):
        self.raw_settings = copy.copy(environ)

    def get_raw_setting(self,
                        namespace: Optional[str],
                        canonical_name: str,
                        aliases: Optional[SettingAliases],
                        as_list: bool = False) -> \
            Optional[RawSetting]:
        """
        :param namespace: namespace for config name_or_alias, typically maps to
            a SettingsDefinition class name
        :param canonical_name: a string name, sources from either
            SettingsDefinition property name.
        :param aliases: options SettingAliases instance, specifies aliases from
            definition.
        :param as_list: whether to pull all existing matching values as a list.
        :return: RawSetting if found, else None
        """
        local_name = canonical_name
        if aliases and aliases.env_variable:
            local_name = aliases.env_variable
        formatted_name = camel_to_big_snake(local_name)

        forms = []

        # fully qualified name
        if namespace:
            formatted_namespace = camel_to_big_snake(namespace)
            full_name = f"{formatted_namespace}__{formatted_name}"
            if full_name in self.raw_settings:
                forms.append(RawSetting(
                    full_name, self.raw_settings[full_name]))

        # check for short name
        if formatted_name in self.raw_settings:
            forms.append(RawSetting(
                formatted_name,
                self.raw_settings[formatted_name]))
        if len(forms) > 0:
            return forms[0]
        else:
            return None


class ConfigFileSource(SettingsSource):
    @staticmethod
    def from_filename(filename: str) -> 'ConfigFileSource':
        config_parser = configparser.ConfigParser()
        config_parser.read(filename)
        return ConfigFileSource(config_parser)

    @staticmethod
    def from_string(content: str) -> 'ConfigFileSource':
        config_parser = configparser.ConfigParser()
        config_parser.read_string(content)
        return ConfigFileSource(config_parser)

    def __init__(self, config_parser: configparser.ConfigParser):
        self.config_parser = config_parser

    def get_raw_setting(self,
                        namespace: Optional[str],
                        canonical_name: str,
                        aliases: Optional[SettingAliases],
                        as_list: bool = False) -> \
            Optional[RawSetting]:
        """
        :param namespace: namespace for config name_or_alias, typically maps to
            a SettingsDefinition class name
        :param canonical_name: a string name, sources from either
            SettingsDefinition property name.
        :param aliases: options SettingAliases instance, specifies aliases from
            definition. Ignored in this implementation.
        :param as_list: whether to pull all existing matching values as a list.
        :return: RawSetting if found, else None
        """
        if namespace is None:
            return None

        value: Optional[str] = \
            self.config_parser.get(namespace, canonical_name, fallback=None)
        if value is None:
            return None
        else:
            return RawSetting(canonical_name, value)


class SettingsDefinition(object):
    @staticmethod
    def discover() -> Set[type]:
        subclasses: Set[type] = set()
        work = [SettingsDefinition]
        while work:
            parent = work.pop()
            for child in parent.__subclasses__():
                if child not in subclasses:
                    subclasses.add(child)
                    work.append(child)
        return subclasses

    @classmethod
    def load(cls,
             args: Union[List[str], None] = None,
             env: FlexibleEnvironType = os.environ,
             config_files: Union[List[str], None] = None):
        sources: List[SettingsSource] = []

        if not args:
            args = sys.argv

        if not config_files:
            config_files = []
            # check environ config
            # this is a PATH-like string, containing either files or directories
            # directories are not traversed recursively
            env_var = os.environ.get('HEARE_CONFIG_PATH', '')
            parts = env_var.split(os.pathsep)
            for part in parts:
                if os.path.isdir(part):
                    for f in glob.glob(part + os.path.sep + '*.ini'):
                        config_files.append(f)
                if os.path.isfile(part):
                    config_files.append(part)

        for file in config_files:
            if os.path.exists(file):
                sources.append(ConfigFileSource.from_filename(file))

        if env:
            sources.append(EnvironSettingsSource(env))
        if args:
            sources.append(CLISettingsSource(args))

        return SettingsDefinition.load_for_class(cls, sources)

    @classmethod
    def load_for_class(cls, settings_class,
                       settings_sources: List[SettingsSource]):
        result = settings_class()
        setting_specs = {}
        intermediate_results: Dict[str, List[RawSetting]] = dict()

        for name, value in settings_class.__dict__.items():
            if not (isinstance(value, Setting)
                    or isinstance(value, ListSetting)):
                continue

            setting_specs[name] = (name, value)
            intermediate_results[name] = []

            for source in settings_sources:
                raw_setting: Optional[RawSetting] = source.get_raw_setting(
                    settings_class.__name__, name, value.aliases,
                    as_list=isinstance(value, ListSetting))

                if raw_setting:
                    intermediate_results[name].append(raw_setting)

        # apply intermediate results to a fully hydrated config object
        for name, (_, setting_spec) in setting_specs.items():
            setting_candidates = intermediate_results.get(name, [])

            if setting_spec.required and \
                    not (setting_candidates or setting_spec.default):
                raise ValueError(
                    f"Required config not satisfied: {name}, {setting_spec}"
                )

            if setting_candidates:
                if isinstance(setting_candidates[0].raw_value, bool):
                    value = setting_candidates[0].raw_value
                else:
                    try:
                        value = setting_spec.from_raw_value(
                            setting_candidates[0].raw_value
                        )
                    except ValueError as ex:
                        raise ValueError(
                            f"Error parsing value of {result.__class__.__name__}.{name}: {ex}"
                        )
            else:
                value = setting_spec.default

            setattr(
                result,
                name, setting_spec.to_gettable(
                    value
                )
            )

        return result
