
# region [Imports]

# * Standard Library Imports -->
import os
import configparser
from typing import Union, Callable, List, Set, Tuple, Iterable, Any
from datetime import datetime, timedelta
from pprint import pprint
import re
import asyncio
from hashlib import md5, sha256, sha512, blake2b, blake2s
# * Third Party Imports -->
from fuzzywuzzy import fuzz

# * Gid Imports -->
import gidlogger as glog
import logging
# * Local Imports -->
from gidconfig.data.enums import Get, Fallback
from gidconfig.utility.functions import readit
from gidconfig.utility.decorators import debug_timing_print, debug_timing_log
# endregion [Imports]


# region [Logging]

log = glog.aux_library_logger(__name__)
log.info(glog.imported(__name__))
# endregion [Logging]


class ConfigHandler(configparser.ConfigParser):
    allowed_default_settings = ['defaults',
                                'dict_type',
                                'allow_no_value',
                                'delimiters',
                                'comment_prefixes',
                                'inline_comment_prefixes',
                                'strict',
                                'empty_lines_in_values',
                                'default_section',
                                'interpolation',
                                'converters',
                                'auto_read',
                                'auto_save']

    default_settings = {"auto_read": True,
                        "auto_save": True,
                        "allow_no_value": True}

    def __init__(self, config_file=None, auto_read=True, auto_save=True, allow_no_value=True, **kwargs):
        super().__init__(**kwargs, allow_no_value=allow_no_value)
        self.config_file = '' if config_file is None else config_file
        self.auto_read = auto_read
        self.auto_save = auto_save
        self._method_select = {Get.basic: self.get, Get.boolean: self.getboolean, Get.int: self.getint, Get.list: self.getlist, Get.path: self.get_path, Get.datetime: self.get_datetime}
        self.annotation_replacements = {'[DEFAULT]': 'Options in this section are used if those options are not set in a Section'}

        if self.auto_read is True:
            self.read(self.config_file)

    @classmethod
    def set_default_setting(cls, key, value):
        if key not in cls.allowed_default_settings:
            raise KeyError(f'"{key}" is not an allowed default setting')
        cls.default_settings[key] = value

    def getlist(self, section, option, delimiter=',', as_set=False, casefold_items=False, fallback_option: str = None):
        _raw = self.get(section, option, fallback=self.get(section, fallback_option) if fallback_option is not None else fallback_option).strip()
        if _raw.endswith(delimiter):
            _raw = _raw.rstrip(delimiter)
        if _raw.startswith(delimiter):
            _raw = _raw.lstrip(delimiter).strip()
        _out = _raw.replace(delimiter + ' ', delimiter).split(delimiter)
        _out = list(map(lambda x: x.strip(), _out))
        if casefold_items is True:
            _out = list(map(lambda x: x.casefold(), _out))
        if as_set is True:
            _out = set(_out)
        return _out

    def list_from_keys_only(self, section, as_set=True):
        _result = self.options(section)
        _out = []
        for line in _result:
            if line != '':
                _out.append(line)
        if as_set is True:
            _out = set(_out)
        return _out

    def get_path(self, section, option, cwd_symbol='+cwd+'):
        _raw_path = self.get(section, option)
        if cwd_symbol in _raw_path:
            _out = _raw_path.replace(cwd_symbol, os.getcwd()).replace('\\', '/')
        elif '+userdata+' in _raw_path:
            _out = _raw_path.replace('+userdata+', os.getenv('APPDATA')).replace('\\', '/')
        elif _raw_path == 'notset':
            _out = None
        else:
            _out = os.path.join(_raw_path).replace('\\', '/')
        return _out

    def _best_fuzzymatch(self, in_term, in_targets: Union[list, set, frozenset, tuple, dict]):
        # Todo: replace with process.extractOne() from fuzzywuzzy!
        _rating_list = []
        for _target in in_targets:
            _rating_list.append((_target, fuzz.ratio(in_term, _target)))
        _rating_list.sort(key=lambda x: x[1], reverse=True)
        log.debug("with a fuzzymatch, the term '%s' was best matched to '%s' with and Levenstein-distance of %s", in_term, _rating_list[0][0], _rating_list[0][1])
        return _rating_list[0][0]

    def get_timedelta(self, section, option, amount_seperator=' ', delta_seperator=','):
        _raw_timedelta = self.get(section, option)
        if _raw_timedelta != 'notset':
            _raw_timedelta_list = _raw_timedelta.split(delta_seperator)
            _arg_dict = {'days': 0,
                         'seconds': 0,
                         'microseconds': 0,
                         'milliseconds': 0,
                         'minutes': 0,
                         'hours': 0,
                         'weeks': 0}
            for raw_delta_data in _raw_timedelta_list:
                _amount, _typus = raw_delta_data.strip().split(amount_seperator)
                _key = self._best_fuzzymatch(_typus, _arg_dict)
                _arg_dict[_key] = float(_amount) if '.' in _amount else int(_amount)
            return timedelta(**_arg_dict)

    def get_datetime(self, section, option, dtformat=None):
        _date_time_string = self.get(section, option)
        if _date_time_string == "notset":
            return None
        _dtformat = '%Y-%m-%d %H:%M:%S' if dtformat is None else dtformat
        return datetime.strptime(_date_time_string, _dtformat).astimezone()

    def set_datetime(self, section, option, datetime_object, dtformat=None):
        _dtformat = '%Y-%m-%d %H:%M:%S' if dtformat is None else format
        self.set(section, option, datetime_object.strftime(_dtformat))
        if self.auto_save is True:
            self.save()

    def set(self, section, option, value):
        if isinstance(value, (list, set, tuple)):
            value = ', '.join(list(map(str, value)))
        if not isinstance(value, str):
            value = str(value)
        super().set(section, option, value)
        if self.auto_save is True:
            self.save()

    def enum_get(self, section: str, option: str, typus: Get = Get.basic):
        return self._method_select.get(typus, self.get)(section, option)

    def save(self):
        with open(self.config_file, 'w') as confile:
            self.write(confile)
        self.read()

    def read(self, filename=None):
        _configfile = self.config_file if filename is None else filename
        super().read(_configfile)

    def set_annotation_replacement(self, replacements: dict):
        for key, value in replacements.items():
            self.annotation_replacements[key] = value

    def get_annotated_config(self):
        content = readit(self.config_file)
        for target, annotation in self.annotation_replacements.items():
            replacement = f";; {annotation}\n{target}"
            content = content.replace(target, replacement)
        return content

    def options(self, section: str) -> List[str]:

        return [option for option in super().options(section) if option not in self.defaults()]

    @classmethod
    def from_defaults(cls, config_file):
        return cls(config_file, **cls.default_settings)

    def __contains__(self, item: object):
        return item in self.sections()

    def __str__(self):
        return os.path.splitext(os.path.basename(self.config_file))[0].upper()


class SingleAccessConfigHandler(ConfigHandler):

    allowed_default_settings = ['defaults',
                                'dict_type',
                                'delimiters',
                                'inline_comment_prefixes',
                                'strict',
                                'empty_lines_in_values',
                                'default_section',
                                'interpolation',
                                'converters',
                                'auto_read',
                                'auto_save',
                                'list_delimiter',
                                'comment_marker',
                                'top_comment',
                                'read_before_retrieve']

    default_settings = {"auto_read": True,
                        "auto_save": True,
                        "allow_no_value": True,
                        "list_delimiter": ',',
                        "comment_marker": '#',
                        "comment_prefixes": '~',
                        "read_before_retrieve": False}

    bool_true_values = {'yes', 'true', '+', 'y', 'on', 'enabled', 'positive'}
    bool_false_values = {'no', 'false', '-', 'n', 'off', 'disabled', 'negative'}

    def __init__(self, config_file=None, auto_read=True, auto_save=True, allow_no_value=True, list_delimiter=',', read_before_retrieve=False, comment_marker='#', top_comment: Union[str, Iterable] = None, comment_prefixes='~', ** kwargs):
        self.section_comments = {}
        self.read_before_retrieve = read_before_retrieve
        self.section_header_size = 150
        self.section_header_chars = {'top': '▲', 'bottom': '▼', 'middle': '▌'}
        self.comment_marker = comment_marker
        self.top_comment_regex = re.compile(r"^[^\[\]]*?(?=\[)", re.DOTALL)
        self._top_comment = self._validate_top_comment(top_comment) if top_comment is not None else top_comment
        super().__init__(config_file=config_file, auto_read=auto_read, auto_save=auto_save, allow_no_value=allow_no_value, comment_prefixes=comment_prefixes, ** kwargs)
        self.list_delimiter = list_delimiter
        self.file_hash_when_loaded = ""

        self.typus_table = {Any: self._auto_convert_value,
                            str: str,
                            int: int,
                            float: float,
                            list: self._as_list,
                            List: self._as_list,
                            List[str]: self._as_list,
                            List[int]: self._as_list_int,
                            List[float]: self._as_list_float,
                            List[bool]: self._as_list_bool,
                            set: self._as_set,
                            Set: self._as_set,
                            Set[str]: self._as_set,
                            Set[int]: self._as_set_int,
                            Set[float]: self._as_set_float,
                            Set[bool]: self._as_set_bool,
                            tuple: self._as_tuple,
                            Tuple: self._as_tuple,
                            Tuple[str]: self._as_tuple,
                            Tuple[int]: self._as_tuple_int,
                            Tuple[float]: self._as_tuple_float,
                            Tuple[bool]: self._as_tuple_bool,
                            bool: self._as_bool}

    @debug_timing_log(log)
    def get_config_file_hash(self, in_file=None):
        _file = self.config_file if in_file is None else in_file
        with open(_file, 'rb') as f:
            return md5(f.read()).hexdigest()

    @property
    def top_comment(self):
        return self._top_comment

    @top_comment.setter
    def top_comment(self, value: Union[Iterable, str]):
        self._top_comment = self._validate_top_comment(value)

    def _validate_top_comment(self, top_comment):
        if isinstance(top_comment, str):
            top_comment = top_comment.splitlines()
        _out = []
        for line in top_comment:
            if not line.startswith(self.comment_marker):
                line = f"{self.comment_marker} {line.strip()}"
            _out.append(line)
        return '\n'.join(_out)

    @debug_timing_log(log)
    def retrieve(self, section, option, typus=Any, *, fallback_section: str = None, fallback_option: str = None, direct_fallback=Fallback.NULL, mod_func: Callable = None):
        if self.read_before_retrieve is True or self.file_hash_when_loaded != self.get_config_file_hash():
            log.debug("config file %s has changed content", os.path.basename(self.config_file))
            self.read()

        raw_data = self.get(section=section, option=option, fallback=None)
        raw_data = raw_data if raw_data not in ['', None] else self.defaults().get(option, None)

        if raw_data in [None, '']:
            if direct_fallback is not Fallback.NULL:
                return direct_fallback
            if fallback_option is not None:
                fallback_section = section if fallback_section is None else fallback_section
                raw_data = self.get(fallback_section, fallback_option, fallback=None)
                raw_data = raw_data if raw_data not in ['', None] else self.defaults().get(fallback_option)
            else:
                if self.has_section(section) is False:
                    raise configparser.NoSectionError(section)
                if self.has_option(section, option) is False:
                    raise configparser.NoOptionError(option, section)

        data = self.typus_table.get(typus)(raw_data)
        if mod_func is not None:
            if type(data) in [list, tuple, set]:
                data = type(data)(map(mod_func, data))
            else:
                data = mod_func(data)
        return data

    def _as_list(self, raw_data):
        raw_data = raw_data.strip(self.list_delimiter).strip()

        list_data = raw_data.split(self.list_delimiter)
        return list(map(lambda x: x.strip(), list_data))

    def _as_list_int(self, raw_data):
        list_data = self._as_list(raw_data)
        return list(map(int, list_data))

    def _as_list_float(self, raw_data):
        list_data = self._as_list(raw_data)
        return list(map(float, list_data))

    def _as_list_bool(self, raw_data):
        list_data = self._as_list(raw_data)
        return list(map(self._as_bool, list_data))

    def _as_set(self, raw_data):
        return set(self._as_list(raw_data))

    def _as_set_int(self, raw_data):
        return set(self._as_list_int(raw_data))

    def _as_set_float(self, raw_data):
        return set(self._as_list_float(raw_data))

    def _as_set_bool(self, raw_data):
        return set(self._as_list_bool(raw_data))

    def _as_tuple(self, raw_data):
        return tuple(self._as_list(raw_data))

    def _as_tuple_int(self, raw_data):
        return tuple(self._as_list_int(raw_data))

    def _as_tuple_float(self, raw_data):
        return tuple(self._as_list_float(raw_data))

    def _as_tuple_bool(self, raw_data):
        return tuple(self._as_list_bool(raw_data))

    def _as_bool(self, raw_data):
        raw_data = raw_data.casefold()
        if raw_data in self.bool_true_values:
            return True
        if raw_data in self.bool_false_values:
            return False
        else:
            raise ValueError(f"'{raw_data}' can not converted to a bool value")

    def _make_section_header(self, section):
        section = f" {section.upper()} "
        top = f'{self.comment_marker} {self.section_header_chars.get("top")*self.section_header_size}'
        middle = f'{self.comment_marker} {section.center(self.section_header_size, self.section_header_chars.get("middle"))}'
        bottom = f'{self.comment_marker} {self.section_header_chars.get("bottom")*self.section_header_size}'
        return '\n'.join(['\n', top, middle, bottom, '\n'])

    def _reset_section_comments(self, filename=None):
        filename = self.config_file if filename is None else filename
        _new_content = []
        with open(filename, 'r') as f:
            old_content = f.read()
        content_lines = old_content.splitlines()
        for index, line in enumerate(content_lines):
            for key, value in self.section_comments.items():
                if line == f'[{key}]':
                    line = self._make_section_header(key) + '\n'.join(value + [f'[{key}]'])
            if not line.startswith(self.comment_marker) and line != '' and not line.startswith('\t') and not content_lines[index + 1].startswith('\t'):
                line = line + '\n'
            _new_content.append(line)
        with open(filename, 'w') as f:
            if self.top_comment is not None:
                f.write(self._make_section_header('instructions') + '\n')
                f.write(self.top_comment + '\n\n' + f'# {"-"*self.section_header_size}\n\n')
            f.write('\n'.join(_new_content))

    def append(self, section, option, value):
        if not self.has_section(section):
            raise configparser.NoSectionError(section)
        if not self.has_option(section, option):
            raise configparser.NoOptionError(option, section)
        if isinstance(value, (list, set, tuple)):
            value = ', '.join(value)
        if not isinstance(value, str):
            value = str(value)

        data = self.retrieve(section, option, typus=str)
        data += ', ' + value
        self.set(section, option, data)

    @debug_timing_log(log)
    def save(self, filename=None):
        filename = self.config_file if filename is None else filename
        with open(filename, 'w') as f:
            self.write(f)
        self._reset_section_comments(filename)

    async def async_save(self, filename=None):
        await asyncio.to_thread(self.save, filename=filename)

    @debug_timing_log(log)
    def read(self, filename=None):
        _configfile = self.config_file if filename is None else filename
        if filename is not None:
            self.config_file = filename
        self.file_hash_when_loaded = self.get_config_file_hash()
        _cleaned_content = []
        _buffered_comment_lines = []
        _current_section = None
        with open(_configfile, 'r') as f:
            content_lines = f.read().splitlines()
        for line in reversed(content_lines):

            if line.startswith('['):
                _current_section = line.strip('[]')

            elif line.startswith(f"{self.comment_marker} {self.section_header_chars.get('top')}") or line.startswith(f"{self.comment_marker} {self.section_header_chars.get('middle')}") or line.startswith(f"{self.comment_marker} {self.section_header_chars.get('bottom')}"):
                line = None
            elif _current_section is not None and line.startswith(self.comment_marker):
                _buffered_comment_lines.append(line)
                line = None
            elif _current_section is not None and not line.startswith(self.comment_marker):
                self.section_comments[_current_section] = list(reversed(_buffered_comment_lines))
                _current_section = None
                _buffered_comment_lines = []

            if line is not None:
                _cleaned_content.append(line)
        if _current_section is not None:
            self.section_comments[_current_section] = list(reversed(_buffered_comment_lines))

        new_content = '\n'.join(reversed(_cleaned_content))
        if self.top_comment is None:
            match = self.top_comment_regex.match(new_content)
            if match:
                self.top_comment = match.group().replace(self._make_section_header('instructions') + '\n', '').replace(f'# {"-"*self.section_header_size}\n\n', '').strip('\n')
        self.clear()
        super().read_string(self.top_comment_regex.sub('', new_content))

    def options(self, section: str) -> List[str]:
        options = super().options(section)
        return [option for option in options if self.comment_marker not in option]

    def _list_auto_convert_value(self, value: str):
        _out = []
        for item in value.split(self.list_delimiter):
            item = item.strip()
            _out.append(self._auto_convert_value(item))
        return _out

    def _auto_convert_value(self, value: str):
        if value is None:
            return None
        value = value.casefold()
        if self.list_delimiter in value:
            return self._list_auto_convert_value(value)
        if value.isdigit() is True:
            return int(value)
        if '.' in value and value.replace('.', '', 1).isdigit():
            return float(value)
        if value in self.bool_true_values:
            return True
        if value in self.bool_false_values:
            return False
        return value

    @debug_timing_log(log)
    def to_dict(self):
        _out = {'default': {}}
        for default_option, default_value in self.defaults().items():
            if self.comment_marker not in default_option:
                _out['default'][default_option] = self._auto_convert_value(default_value)
        for section in self.sections():
            _out[section] = {}
            for option in self.options(section):
                value = self.retrieve(section, option, typus=str, direct_fallback=None)
                _out[section][option] = self._auto_convert_value(value)
        return _out

    async def async_to_dict(self):
        return await asyncio.to_thread(self.to_dict)


if __name__ == '__main__':
    pass
