from .extensions import Extensions
from .loader_ignore import LoaderIgnore

from pathlib import Path
import os
import ast
import importlib.util
import inspect
import sys
import yaml
import json
from typing import Any



class Loader:
    __slots__ = (
        '__config',
        # files
        '__paths',
        # env
        '__env_prefix', '__env_separator',
        #
        '__ignore',
        '__extensions',
        '__order',
        #
        '__read_only'
    )

    __instance = None

    supported_extensions: list[str] = [
        Extensions.py,
        Extensions.yaml,
        Extensions.json,
        Extensions.env
    ]


    def __new__(cls, *args, **kwargs):
        if cls.__instance is None:
            cls.__instance = super(Loader, cls).__new__(cls)
        return cls.__instance


    def __init__(
        self,
        /,
        # files
        path: Path|str = None,
        paths: list[Path|str] = None,
        # env
        env_prefix: str = None,
        env_separator: str = None,
        # ignore
        ignore: LoaderIgnore = None,
        # common
        extension: str = None,
        extensions: list[str] = None,
        order: list[str] = None
    ):
        """
        Init Config[Loader] class.

        :param path: config path
        :param paths: config path list
        :param env_prefix: env-vars prefix
        :param env_separator: env-var separator
        :param ignor: objects to be ignored
        :param extension: config file extension
        :param extensions: config files extensions list (default: py / yaml / json / env)
        :param order: load order (default: py->yaml->json->env)
        :return:
        """

        if hasattr(self, '__config'):
            return

        # params init -----

        # file-paths
        if paths is None:
            paths = []
        else:
            paths = [
                # fix: ENV location AS IS, convert only real paths
                Path(p) if not p == Extensions.env else Extensions.env
                for p in paths
            ]

        if path is not None:
            # fix: ENV location AS IS, convert only real paths
            if not path == Extensions.env:
                path = Path(path)
            if path not in paths:
                paths.append(path)

        if len(paths) == 0:
            paths = [Path('./configs')]

        # extensions
        if extensions is None:
            extensions = []
        else:
            extensions = [e.lower() for e in extensions]

        if extension is not None and extension.lower() not in extensions:
            extensions.append(extension.lower())

        for ext in extensions:
            if ext not in self.supported_extensions:
                raise Exception(f'Extention "{ext}" not supported')

        if len(extensions) == 0:
            extensions = self.supported_extensions

        if ignore is None:
            ignore = LoaderIgnore()

        if order is None:
            order = self.supported_extensions

        # init -----
        self.__paths = paths
        self.__env_prefix = env_prefix
        self.__env_separator = env_separator
        self.__ignore = ignore
        self.__extensions = extensions
        self.__order = order

        self.__config: dict[str, Any] = {}
        self.__load_config()
        pass


    def __load_config(self):
        """
        Load all configs.

        :return:
        """

        # collect config files
        config_files = []

        for path in self.__paths:
            # env-vars =====
            if f'{path}'.lower() == Extensions.env:
                config_files.append(
                    # file = (ext, path)
                    (Extensions.env, None)
                )
                continue
            else:  # config-files =====
                if not path.exists():
                    print(f'warning: Path "{path}" not found: {path.absolute()}')
                    continue

                files = []
                if path.is_file():
                    files = [path]
                if path.is_dir():
                    files = path.rglob('*')

                for file in files:
                    if file.is_file() and not self.__ignore.is_ignored(path=file):  # filtered file-path
                        ext = file.suffix[1:].lower()

                        if ext in self.__extensions:
                            config_files.append(
                                # file = (ext, path)
                                (ext, file)
                            )

        # sort files: by priority asc, by name asc
        config_files.sort(
            key=lambda file: (
                # file = (ext, path)
                self.__order.index(file[0]) if file[0] in self.__order else 99999,
                file[1].name if file[1] is not None else ''
            )
        )

        # load and combine configs
        for ext, path in config_files:
            if ext == Extensions.env:
                self.__enrich(
                    current=self.__config,
                    new=self.__load_config__from_env(
                        env_vars={  # filtered env-vars
                            k: v
                            for k, v in os.environ.items()
                            if not self.__ignore.is_ignored(name=k)
                        },
                        prefix=self.__env_prefix,
                        separator=self.__env_separator
                    )
                )
            else:
                self.__enrich(
                    current=self.__config,
                    new=self.__load_config__from_file(
                        path=path,
                        ext=ext
                    )
                )

        pass


    @staticmethod
    def __enrich(current: dict[str, Any], new: dict[str, Any] = None):
        """
        Enrich current config with new one.

        :param current: current config dict
        :param new: new config dict
        :return:
        """

        if new is None:
            new = {}

        if len(new) == 0:
            return

        for key, value in new.items():
            if isinstance(value, dict) and key in current and isinstance(current[key], dict):
                Loader.__enrich(current[key], value)
            else:
                current[key] = value

        pass


    @staticmethod
    def __load_config__from_env(
        env_vars: dict[str, str],
        prefix: str = None,
        separator: str = None,
    ) -> dict[str, Any]:
        """
        Load config from ENV.

        :param env_vars: env-vars dict
        :param prefix: prefix (default='')
        :param separator: separator (default=':')
        :return: config dict
        """

        if separator is None:
            separator = ':'

        prefix_ = f'{prefix}{separator}' if prefix is not None else ''

        config = {}

        for key, value in env_vars.items():
            if not key.startswith(prefix_):
                continue

            # extract params & adjust names
            parts = [
                part.lower().replace('-', '_')
                for part in key[len(prefix_):].split(sep=separator)
                if part
            ]

            if not parts:
                continue

            # type casting
            if value.lower() in ('true', 'yes', 'on', 'enabled'):
                value_ = True
            elif value.lower() in ('false', 'no', 'off', 'disabled'):
                value_ = False
            else:
                try:
                    value_ = ast.literal_eval(value)
                except (ValueError, SyntaxError):
                    value_ = value

            # deep set
            dict_ = config

            for part in parts[:-1]:
                if part not in dict_ or not isinstance(dict_[part], dict):
                    dict_[part] = {}
                dict_ = dict_[part]

            dict_[parts[-1]] = value_

        return config


    @staticmethod
    def __load_config__from_file(path: Path, ext: str) -> dict[str, Any]:
        """
        Load config from file.

        :param path: file path
        :param ext: file extension
        :return:  config dict
        """

        ext = ext.lower()

        if ext not in Loader.supported_extensions:
            raise Exception(f'Extension "{ext}" not supported')

        config = {}

        if path.exists():
            try:
                if ext == Extensions.py:
                    module_name = f'config_module_{hash(str(path.absolute()))}'
                    spec = importlib.util.spec_from_file_location(
                        name=module_name,
                        location=f'{path}'
                    )

                    if spec and spec.loader:
                        module = importlib.util.module_from_spec(spec)
                        sys.modules[module_name] = module

                        try:
                            spec.loader.exec_module(module)

                            for attribute__name in dir(module):
                                if not attribute__name.startswith('_'):
                                    attribute__value = getattr(module, attribute__name)

                                    if not any((
                                        inspect.ismodule(attribute__value),
                                        inspect.isclass(attribute__value),
                                        inspect.isfunction(attribute__value)
                                    )):
                                        config[attribute__name] = attribute__value

                        finally:
                            if module_name in sys.modules:
                                del sys.modules[module_name]

                elif ext == Extensions.yaml:
                    with path.open('r', encoding='utf-8') as f:
                        config = yaml.safe_load(stream=f)

                elif ext == Extensions.json:
                    with path.open('r', encoding='utf-8') as f:
                        config = json.load(fp=f)

            except Exception as exc:
                print(f'Failed to load "{path}": {exc}')

        return config


    @property
    def config(self) -> dict[str, Any]:
        return self.__config

    pass
