from __future__ import annotations

import jinja2
from typing import Any, Callable, Mapping, Optional, Sequence, Union, cast
from jinja2.nativetypes import NativeEnvironment
from .attr_dict import AttrDict
from .config_var_def import ConfigVarDef
from .utils import is_sequence, _undef
from pathlib import Path
from dataclasses import dataclass, field
from .configvar_expression import (
    ConfigVarExpression,
    config_path_expression,
    PathOperand,
    StringOperand,
    NumericOperand,
)


@dataclass
class SubstitutionResult:
    """
    A class to hold the result of a substitution operation.

    includes the rendered values of all candidate templates and the final rendered value.
    """

    rendered_value: Any = field(default=_undef)
    rendered_candidates: list[Any] = field(default_factory=list)


RenderContext = Mapping[str, Any]
SubstitutionType = Union[str, Path, ConfigVarDef, ConfigVarExpression]


class VarSubstituer:
    """
    A class to perform variable substitution on configuration variables.

    It resolve ConfigVarExpression and render Jinja2 templates.
    """

    # Native Jinja env: returns native Python objects when possible
    _JINJA_ENV: NativeEnvironment = NativeEnvironment(
        autoescape=False,
        undefined=jinja2.StrictUndefined,
    )

    # Filter style: {{ "/tmp" | P / "base" }}
    _JINJA_ENV.filters["p"] = config_path_expression

    # Global function style: {{ P("/tmp") / "base" }}
    _JINJA_ENV.globals["P"] = config_path_expression

    _var_solved: AttrDict

    def __init__(self, var_solved: AttrDict) -> None:
        """Create a VarSubstituer with the provided context for variable substitution.
        var_solved must be dynamic and contain the currently resolved values."""
        self._var_solved = var_solved

    def render_jinja_native(self, template_value: str) -> Any:
        """Render a template with NativeEnvironment and return native objects."""
        template = self._JINJA_ENV.from_string(template_value)
        return template.render(self._var_solved.to_dict())

    def do_substitution(
        self,
        value: Any,
    ) -> Any:
        """
        Render a value by substituing configuration variables with their value and rendereding jinja2 template.



        The value can be:
        - If the value is not a string, nor a Path, nor a ConfigVarDef, nor a ConfigVarExpression nor a dict or a sequence , it is returned as is.
        - If the value is a string it is rendered with Jinja, as we use Native Jinja environment the result can be a Python type that can need substitution itself,
            so we pass the redered value down to test all other cases. (Jinja can't return a jinja string.)
        - If the value is a ConfigVarExpression it is resolved and with the result we do a recurvise call in order to test all cases including the case of a
              Jinja template resulting from the expression resolution.
        - If the value is a Path it is returned as a Path.
        - If the value is a sequence (except strings), each item is resolved recursively and the result is returned as the same sequence type.
        - If the value is a dict, each value is rendered recursively and the result is returned as a dict with the same keys and the rendered values.
        - If the value is a  ConfigVarDef then look in the context if it is already defined, if it is the case the value is resolve recursively and returned,
          otherwise raise an exception.
        """

        val_to_render: Any = value
        if value is None:
            return None
        if isinstance(value, str):
            try:
                # we can't do a recursive call because we will loop on the string case.
                # instead we continue the process with the rendered value
                val_to_render = self.render_jinja_native(value)
            except jinja2.exceptions.UndefinedError as e:
                raise ValueError(f"Undefined variable in template: {value}") from e
            except jinja2.TemplateError as e:
                raise ValueError(
                    f"Error rendering template: {value}. Error: {str(e)}"
                ) from e
        if isinstance(val_to_render, Path):
            res = self.do_substitution(str(val_to_render))
            return Path(res) if res is not None else None

        elif isinstance(val_to_render, ConfigVarDef):
            if val_to_render.fq_name is None or not self._var_solved.has_value(
                val_to_render.fq_name
            ):
                raise ValueError(
                    f"Variable {val_to_render.fq_name} is not defined. Cannot render ConfigVarDef without a value."
                )
            return self.do_substitution(self._var_solved[val_to_render.fq_name])
        elif isinstance(val_to_render, ConfigVarExpression):
            res = val_to_render.resolve(self.do_substitution)
            return res
        elif is_sequence(val_to_render):
            return type(val_to_render)([self.do_substitution(item) for item in val_to_render])  # type: ignore
        elif isinstance(val_to_render, dict):
            return type(val_to_render)(
                {k: self.do_substitution(v) for k, v in val_to_render.items()}
            )
        else:
            return val_to_render

    def resolve_candidate_list(
        self,
        *,
        candidates: SubstitutionType | None | Sequence[SubstitutionType],
        check_exists: Callable[[str], Any],
    ) -> SubstitutionResult:
        """
        Takes a list of candidates values (file paths,env name, value key or file_key) and resolves them in
        order, returning the first one for which check_exists returns a non-_undef value.

        Args:
            candidates: A single string, a Path, a ConfigVarDef or a ConfigVarExpression, or a list of these. Values can be
            potential jinja templates for string.
            check_exists: An predicate to validate the rendered string.
                        returns _undef if not valid, or the value to return if valid.
        Returns:
            A SubstitutionResult containing the rendered candidates and the first rendered value that passed the
            check_exists validation. If no candidate is valid, rendered_value will be _undef.
        """
        res = SubstitutionResult()
        if candidates is None:
            return res

        candidate_list: list[SubstitutionType] = []

        if is_sequence(candidates):
            candidate_list = cast(list[SubstitutionType], candidates)
        else:
            candidate_list = [cast(SubstitutionType, candidates)]

        for raw_candidate in candidate_list:
            rendered = self.do_substitution(raw_candidate)
            res.rendered_candidates.append(rendered)
            valid = check_exists(rendered)
            if valid != _undef:
                res.rendered_value = valid
                return res

        return res
