from __future__ import annotations

import logging
import os
from typing import Any, Callable, Iterable, Mapping, Optional, Sequence, cast
from .config_var_def import ConfigVarDef, Flags
from .attr_dict import AttrDict
from .utils import AppConfigException, Undefined, _undef, is_sequence, PathType
from .var_substitution import VarSubstituer
from .configvar_expression import PathOperand
from pydantic import TypeAdapter, ValidationError
from pathlib import Path


class Solver:
    """Class responsible for solving configuration variables values
    according to the defined search order and applying transformations and validations."""

    _logger = logging.getLogger(__name__)
    _local_flags: Flags
    _values_map: Mapping[str, Any]
    """ The values provided directly to the AppConfig instance."""
    _conffile_values: Optional[AttrDict] = None
    """ The values found in the configuration file."""
    _eval_runner: Callable[[Callable[[str, Any, Any], Any], str, Any], Any]
    """ The function that will run the evaluation of callables for default values and transformations."""
    _var_solved: AttrDict
    """ currently solved config var. Used to handle dependencies between variables and variable substitution """
    _var_substituer: VarSubstituer
    """ The VarSubstituer instance used to substitute variables in the value found for a variable to solve."""

    def __init__(
        self,
        *,
        flags: Flags,
        values_map: Mapping[str, Any],
        eval_runner: Callable[[Callable[[str, Any, Any], Any], str, Any], Any],
        var_solved: AttrDict,
        conffile_values_map: Optional[Mapping[str, Any]] = None,
    ) -> None:
        self._local_flags = flags
        self._values_map = values_map
        self._conffile_values = self.set_conffile_values_map(conffile_values_map)
        self._eval_runner = eval_runner
        self._var_solved = var_solved
        self._var_substituer = VarSubstituer(self._var_solved)

    def set_conffile_values_map(
        self, conffile_values_map: Optional[Mapping[str, Any]]
    ) -> None:
        """
        Set or update the configuration file values map.
        Allow to use the same solver with ou without configuration file.
        """
        if conffile_values_map is not None:
            self._conffile_values = AttrDict(conffile_values_map)

    @classmethod
    def _find_var_in_dict(
        cls, where: Mapping[Any, Any], var_name: str
    ) -> Any | Undefined:
        """
        Search for var_name in a dictionary.
        var_name contains dict keys separated by a dot (.) ie key1.key2.key3
        The left side key key1 is search in the where directory, if it is found
        and it is a dictionary then key2 is search in it and
        all key are searched in sequence.
        Returns _undef if not found, allowing to distinguish between None (explicit null) and missing.
        """
        cur_dic: Mapping[Any, Any] = where
        for key in var_name.split("."):
            # logger = logging.getLogger(f"{cls.__module__}.{cls.__name__}")
            # logger.debug(f"searching for key: {key} in dict : {cur_val}")
            if key in cur_dic:
                cur_dic = cur_dic[key]
                # logger.debug(f"Found key: {key} = {cur_val}")
            else:
                return _undef

        return cur_dic

    def resolve_conffile_path(
        self,
        candidates: Optional[PathOperand | Iterable[PathOperand]],
    ) -> Path | Undefined:
        """
        Resolve the configuration file path from candidates, returns the Path corresponding to the first valid canditate.

        Each candidate can be a ConfigVarDef, a ConfigVarExpression or a string that can be a be resolved with the already resolved variables in this AppConfig.
            - A string is rendered with jinja and transform to a Path
            - A Path is stringified and rededered with jinja, then transform back to a Path
            - A ConfigVarExpression is resolved and returned with its corresponding type and then processed according to its type as above.
            - A ConfigVarDef is resolved to its value and then processed according to its type as above.

        Candidates are valid if a file exists at their rendered path, if no valid candidate is found then the rendered_value attribute of
        the returned object is set to _undef otherwise it contains the the resolution of the first valid candidate.

        The check_exists function check that the file exists at the resolved path and return the path if it exists or _undef if it doesn't exist.

        The validation checks that the file exists at the resolved path. This allows to have multiple
        potential location for the configuration file and use the first one found.
        """
        if candidates is None:
            return _undef

        # Normalize candidates to list of str for resolve_candidate_list
        candidates_list: list[PathOperand] = []
        if not is_sequence(candidates):
            candidates_list = [cast(PathOperand, candidates)]
        else:
            candidates_list = cast(list[PathOperand], candidates)

        # Define existence check for file paths
        def file_exists(p: str | Path) -> Path | Undefined:
            path_obj: Path
            if isinstance(p, str):
                path_obj = Path(p)
            elif isinstance(p, Path):
                path_obj = p
            else:
                raise ValueError(
                    f"Invalid candidate type: {type(p)}. Expected str or Path."
                )

            if path_obj.is_file():
                return path_obj
            else:
                return _undef

        resolution_status = self._var_substituer.resolve_candidate_list(
            candidates=candidates_list, check_exists=file_exists
        )

        return resolution_status.rendered_value

    def resolve_confvar(
        self, *, var_to_solve: ConfigVarDef, scopes: Optional[str | Iterable[str]]
    ) -> Any:
        """
        Assign a value to the given ConfigVarDef according to the search order:
        1. values mapping
        2. environment variables
        3. configuration file
        4. default value
        Applies transformations and validations as specified in the ConfigVarDef.
        If its a Path variable, process relative paths, create directories if needed.

        Returns _undef if no value have been found and the variable is not mandatory or if the variable is out of the given scopes.
        Raises AppConfigException if the variable is mandatory and no value have been found or if validation failed.
        """
        val_found: Any = _undef
        found = False

        cur_flags = self._local_flags.merge(inplace=False, other=var_to_solve.flags)
        assert cur_flags is not None

        # if we are resolving the variables in some specific scopes (ie parameter scope has value)
        # we check if the variable belongs to one of these scopes and if not we skip it, in this
        # case the variable is said to be "out of scope" and has no existence.
        if scopes is None and var_to_solve.scopes is not None:
            return _undef
        # if we are resolving the variable in some specific scopes (ie parameter scope has value) and the variable
        # has also some scopes defined we check if there is at least one scope in common
        if scopes is not None and var_to_solve.scopes is not None:
            test_ctx = []
            # put True in test_ctx for each scope that matches, False otherwise
            if is_sequence(scopes):
                test_ctx = [c in var_to_solve.scopes for c in scopes]
            else:
                test_ctx = [scopes in var_to_solve.scopes]

            # test if there is any True in test_ctx which means that at least one scope matches
            if not any(test_ctx):
                return _undef
        # if the variable has no scope and we are resolving in some specific scopes this means that
        # the variable is common to all scopes so we process it but only once except if the flag
        # allow_override is set to True
        if var_to_solve.scopes is None and scopes is not None:
            if not cur_flags.allow_override and var_to_solve.fq_name in self._var_solved:
                return _undef

        # reading fq_name and reassure type checker that it has a value.
        var_fq_name = var_to_solve.fq_name
        if var_fq_name is None:
            raise AppConfigException("Internal error: var_to_solve.fq_name is None")

        #  Searching variable value in the values mapping
        if (
            self._values_map is not None
            and not cur_flags.no_value_search
            and var_to_solve.cli_keys is not None
        ):
            # in the value map we have directly the variable fq_name as key thanks to the click option callback
            # that restore the fq_name instead of the value_keys.
            if (
                var_fq_name in self._values_map
                and self._values_map[var_fq_name] != _undef
            ):
                val_found = self._values_map[var_fq_name]
                found = True
                self._logger.debug(
                    f"{var_fq_name} -> Found in Values with fq_name as key = {val_found}"
                )

        # Searching variable value in the environment variables
        if (
            not found
            and not cur_flags.no_env_search
            and var_to_solve.env_keys is not None
        ):

            def check_env(k: str) -> Any:
                """check if the key k exists in the environment variables and return its value or _undef"""
                res = os.getenv(k)
                return _undef if res is None else res

            # calling the substituer to resolve the env_keys with variable substitution and jinja rendering, we pass check_env as
            # check_exists to stop at the first existing env key and return its value.
            resolution_status = self._var_substituer.resolve_candidate_list(
                candidates=var_to_solve.env_keys, check_exists=check_env
            )
            value_checked = resolution_status.rendered_value

            if value_checked != _undef:
                val_found = value_checked
                found = True
                self._logger.debug(f"{var_fq_name} -> Found in Env = {val_found}")

        #  Searching variable value in the configuration file
        if (
            not found
            and not cur_flags.no_conffile_search
            and self._conffile_values is not None
            and var_to_solve.file_keys is not None
        ):

            def check_file(k: str) -> Any:
                """check if the key k exists in configuration file and return its value or _undef"""
                if self._conffile_values is None:
                    return False
                return (
                    _undef
                    if not self._conffile_values.has_value(k)
                    else self._conffile_values[k]
                )

            # calling the substituer to resolve the file_keys with variable substitution and jinja rendering, we pass check_file as
            # check_exists to stop at the first existing file key and return its value.
            resolution_status = self._var_substituer.resolve_candidate_list(
                candidates=var_to_solve.file_keys, check_exists=check_file
            )
            value_checked = resolution_status.rendered_value
            # None is a valid value from config file, _undef means not found
            if value_checked != _undef:
                val_found = value_checked
                found = True
                self._logger.debug(
                    f"{var_fq_name} -> Found in Configuration File = {val_found}"
                )

        # Setting variable value to the default value if defined. The eval form can return _undef
        # to indicate that there is no default value to use, allowing to distinguish between no default value
        # and an explicit default value of None.
        # Even when allow_override is True  default value can't override an already found value (a value found in a previous call to resolve_vars()).
        if (
            var_fq_name not in self._var_solved
            and not found
            and var_to_solve.default is not _undef
        ):
            if var_to_solve.default is not None and callable(var_to_solve.default):
                val_found = self._eval_runner(var_to_solve.default, var_fq_name, None)
            else:
                val_found = var_to_solve.default
            found = val_found != _undef
            self._logger.debug(f"{var_fq_name} -> Found in Default Value = {val_found}")

        # Raise exception if no value was found and variable is mandatory and if we have not
        # already a value for this variable (in case of override with scopes for example)
        if not found and var_to_solve.mandatory and var_fq_name not in self._var_solved:
            raise AppConfigException(f"No value for var {var_fq_name} in scope {scopes}")

        if found:
            if isinstance(val_found, Undefined):
                raise AssertionError(
                    f"Internal error on var {var_fq_name}: val_found is _undef while found=True"
                )
            # spliting lists
            if (
                var_to_solve.split_to_list
                and val_found is not None
                and isinstance(val_found, str)
            ):
                sep: str = (
                    ","
                    if isinstance(var_to_solve.split_to_list, bool)
                    else var_to_solve.split_to_list
                )
                val_found = val_found.split(sep)

            # Transform the variable value if specified, we do it before rendering because the transformation can produce a value
            # that need to be rendered and also because the transformation can produce a non string value that we don't want to render.
            if var_to_solve.transform is not None:
                val_transfo = self._eval_runner(
                    cast(Callable, var_to_solve.transform),
                    var_fq_name,
                    val_found,
                )
                self._logger.debug(
                    f"{var_fq_name} -> Value Transformed: {val_found} => {val_transfo}"
                )
                val_found = val_transfo

            # Apply variable substitution and Jinja template rendering to the found value using the currently solved variables.
            # we delegate to the var_substituer the handling of different type of value and the rendering or not according to the type.
            if val_found is not None:

                if is_sequence(val_found):
                    # if we have a list of value we render each value separately
                    val_found = [
                        self._var_substituer.do_substitution(v)
                        for v in cast(Iterable, val_found)
                    ]
                else:
                    val_found = self._var_substituer.do_substitution(val_found)

            # validate and cast the variable value using pydantic TypeAdapter
            if var_to_solve.type_info is not None and val_found is not None:
                try:
                    ta = TypeAdapter(
                        var_to_solve.type_info,
                        config={"coerce_numbers_to_str": True, "strict": False},
                    )
                    val_found = ta.validate_python(val_found)
                except ValidationError as e:
                    raise AppConfigException(
                        f"Validation failed for var {var_fq_name}: {e}"
                    )

            # Make special treatment for paths
            if (
                not var_to_solve.no_dir_processing
                and var_to_solve.type_info == Path
                and val_found is not None
            ):
                new_val: list[Path] | Path | None = None

                val_found = self._process_paths(
                    value=val_found, var=var_to_solve, var_solved=self._var_solved
                )

        return val_found

    def _process_paths(
        self, *, value: Any, var: ConfigVarDef, var_solved: AttrDict
    ) -> Path:
        """
        Run the path processing for a single Path variable.
        """
        var_value: Path
        if isinstance(value, str):
            var_value = Path(value)
        elif isinstance(value, Path):
            var_value = value
        else:
            raise Exception("not a path")

        res: Path = var_value
        # First process the can_be_relative_to case.
        if var.can_be_relative_to is not None:

            if var_value.is_absolute():
                res = var_value
            else:
                # resolving the root directory from which to be relative to.
                can_be_relative = var.can_be_relative_to

                root_dir: Path
                if isinstance(can_be_relative, ConfigVarDef):
                    can_be_relative = can_be_relative.fq_name
                if can_be_relative is None:
                    raise AppConfigException(
                        f"Internal error: can_be_relative_to is None for var {var.fq_name}"
                    )
                if (
                    isinstance(can_be_relative, str)
                    and can_be_relative in var_solved
                    and var_solved[can_be_relative] != _undef
                ):
                    root_dir = Path(var_solved[can_be_relative])
                else:
                    root_dir = Path(can_be_relative)
                res = root_dir.joinpath(var_value)

        # Expanding user home and resolving dots in path
        res = res.expanduser().resolve()

        # create directory if make_dirs is not None
        if var.make_dirs:
            if var.make_dirs == PathType.Dir:
                res.mkdir(parents=True, exist_ok=True)
            else:
                res.parent.mkdir(parents=True, exist_ok=True)
        return res
