"""
Reusable config parsing and validating engine with schema-as-code
"""
import yaml
from functools import lru_cache 
from typing import Any, Callable, get_origin, get_args, Union, Optional

from configsage.common_decorators import wrap_with_name
from configsage.config_callables import wrap_name_fmt
from configsage.config_error import ConfigValidationError


class Config:
    """
    YAML-backed configuration loader, validator, and object mapper.

    Schema is provided as a Python dict (schema-as-code) by the application developer.

    Supported meta-keys in the schema:
      - _defaults: dict of defaults applied ONLY when field is missing
      - _items: schema for items inside a list
      - _type: expected Python type or union (e.g., str, int, str|None)
      - _enum: allowed values for the field (iterable)
      - _normalize: callable(value)->value (applied optionally or during enum checks)
      - _validator: callable or iterable[callable] that raises on invalid value
      - _required: Whether the field is required (default or missing is False)

    Notes:
      - When `normalize=True`, `_normalize` is applied in-place to config values.
      - Validators run AFTER type and enum checks.
    """

    # Allowed metadata keys
    SCHEMA_META_KEYS: set = {
        "_type",            # defines the strict item type (if scalar is str, int, etc. or list[str] or dict)
        "_default",         # default value to consider even if the field is not in the config file
        "_enum",            # allowed possible values for that field
        "_normalizers",     # functions to call in sequence to normalize
        "_validators",      # functions to call in sequence to validate
        "_required",        # if the field is missing or False then the field can be omitted, otherwise the validation fails
    }

    @wrap_with_name(formatter=wrap_name_fmt)
    def __init__(
        self,
        config_src: Union[str, dict],  # Configuration file path or pre-parsed dict
        *,
        schema: dict = None,  # Schema definition in Python dict format, if none just objectify
        validate: bool = True,  # Whether to run validation checks
        normalize: bool = False,  # Apply normalization in-place
    ):
        """
        Initializes the Config object by loading and validating configuration.

        Params:
            config_src (Union[str, dict]): Path to YAML config file or pre-parsed dictionary.
            schema (dict): Developer-defined schema for the configuration.
            validate (bool): Whether to validate the configuration (defaults to True).
            normalize (bool): Whether to apply normalization to values (defaults to False).
        """
        
        # if schema is passed performs check
        if schema:
            # check if the schema is a dictionary
            if not isinstance(schema, dict):
                raise TypeError("schema must be a dict (from schema.py)")

            # check the schema has valid metadata keys
            self._validate_schema_metadata(schema)
            # load the schema as attribute
            self._schema = schema
            # get the yaml as dictionary
            config_dict = self._load_yaml_or_dict(config_src)
            # process the config for checks etc
            self._process_config_recursively(config_dict,self._schema,normalize,validate,path=[])
        
        # If no schema, just objectify the config data
        else:
            config_dict = self._load_yaml_or_dict(config_src)
            self._schema = None

        # convert the dictionary to an object
        self._init_from_dict(config_dict)



    # --------------------------
    # Helpers
    # --------------------------

    def _node_kind(self, schema: Any) -> str:
        """
        Checks the schema node type (for list _type is mandatory as it's list of something)
        Params:
            schema(Any): it is the schema portion to analyze (not necessarily the root)
        Returns:
            a string naming one of the following field type: 'scalar', 'object', 'list' as expected node type
        """
        # checks if the schema passed is a valid dict
        if not isinstance(schema, dict):
            raise TypeError("invalid schema node")

        # Explicit LIST detection (_type is mandatory here)
        t = schema.get("_type")
        if t:
            origin = get_origin(t)
            if t is list or origin is list:
                return "list"

        # Object if has non-meta children
        child_keys = [k for k in schema if not k.startswith("_")]
        if child_keys:
            return "object"

        # Otherwise scalar
        return "scalar"


    def _load_yaml_or_dict(self, src: Union[str, dict]) -> dict:
        """
        Loads configuration data from either a YAML file or a pre-parsed dictionary.

        Params:
            src (Union[str, dict]): Path to YAML file or pre-parsed dictionary.

        Returns:
            dict: Parsed configuration data as a dictionary.

        Raises:
            TypeError: If `src` is neither a valid file path nor a dictionary.
            Exception: If loading the YAML file fails.
        """
        if isinstance(src, dict):
            return src
        if isinstance(src, str):
            try:
                with open(src, "r", encoding="utf-8") as f:
                    data = yaml.safe_load(f) or {}
                if not isinstance(data, dict):
                    raise TypeError(f"Expected a dictionary at the root of the YAML file")
                return data
            except Exception as e:
                raise Exception(f"Failed to load configuration from {src}: {str(e)}")
        raise TypeError(f"Expected a path (str) or dictionary, got {type(src)}")

    @staticmethod
    @lru_cache(maxsize=None)
    def _parse_type(tp):
        """
        Avoid recomputing get_origin/get_args for the same type many times.
        Static method as functools.lru_cache does not work properly with instance methods unless adding self to the cache key, which is messy
        """
        return get_origin(tp), get_args(tp)

    def _check_type(self, value: Any, expected: Any, path: list[str]) -> None:
        """
        Recursively enforce runtime type checking against schema `_type` (also for nested generics)
        Supports:
            - scalar types (str, int, ...)
            - Union / Optional
            - list[T]
            - dict[K, V]
            - nested combinations (list[list[str]], etc.)
        Params:
            value(any): value to check the type
            expected(any): expected type
            path(list[str]): where the value is (list is used because of recursion)
        Raises: 
            ConfigValidationError on mismatch.
        """
        # get types by the cache to optimize (as this funct is recursively called)
        origin, args = self._parse_type(expected)

        # -------------------------
        # Handle None explicitly
        # -------------------------
        if value is None:
            if origin is Union and type(None) in args:
                return
            if expected is type(None):
                return
            raise ConfigValidationError(
                code="type_mismatch",
                details=f"Type mismatch, got None, expected {expected}",
                path=path,
                value=value,
                expected=expected,
            )

        # -------------------------
        # UNION / OPTIONAL
        # -------------------------
        if origin is Union:
            for candidate in args:
                try:
                    self._check_type(value, candidate, path)
                    return
                except ConfigValidationError:
                    continue
            
            # Format all union candidate types as strings
            expected_str = ", ".join(
                getattr(t, "__name__", str(t)) for t in args
            )
            raise ConfigValidationError(
                code="type_mismatch",
                path=path,
                value=value,
                expected=f"Union[{expected_str}]",
                details=f"Value does not match any type in {expected_str}",
            )

        # -------------------------
        # LIST[T]
        # -------------------------
        if origin is list:
            if not isinstance(value, list):
                raise ConfigValidationError(
                    code="type_mismatch",
                    details=f"Type mismatch, got {type(value)}, expected list",
                    path=path,
                    value=value,
                    expected=list,
                )

            if args:
                item_type = args[0]
                for i, item in enumerate(value):
                    self._check_type(
                        item,
                        item_type,
                        path + [f"[{i}]"]
                    )
            return

        # -------------------------
        # DICT[K, V]
        # -------------------------
        if origin is dict:
            if not isinstance(value, dict):
                raise ConfigValidationError(
                    code="type_mismatch",
                    details=f"Type mismatch, got {type(value)}, expected dict",
                    path=path,
                    value=value,
                    expected=dict,
                )

            if len(args) == 2:
                key_type, val_type = args
                for k, v in value.items():
                    self._check_type(k, key_type, path + ["<key>"])
                    self._check_type(v, val_type, path + [str(k)])
            return

        # -------------------------
        # SIMPLE SCALAR
        # -------------------------
        if not isinstance(value, expected):
            raise ConfigValidationError(
                code="type_mismatch",
                details=f"Type mismatch, got {type(value)}, expected {expected}",
                path=path,
                value=value,
                expected=expected,
            )
            

    ########################################
    # Validate Schema
    ########################################

    def _validate_schema_metadata(self, schema: Any, path: list[str] | None = None) -> None:
        """
        Recursively validate that all metadata keys in schema are allowed.

        Params:
            schema: current schema node
            path: list representing the current traversal path (for error messages)
        """
        # init hte error path
        if path is None:
            path = []

        if not isinstance(schema, dict):
            raise TypeError(f"invalid schema node at {'.'.join(path) or 'root'}: expected dict")

        for key, value in schema.items():
            # Metadata key check
            if key.startswith("_"):
                if key not in self.SCHEMA_META_KEYS:
                    location = ".".join(path) or "root"
                    raise ValueError(
                        f"invalid schema metadata key '{key}' at '{location}'. "
                        f"allowed keys: {sorted(self.SCHEMA_META_KEYS)}"
                    )

            # Recurse into non-metadata keys
            elif isinstance(value, dict):
                self._validate_schema_metadata(value, path + [key])


    def _process_config_recursively(self, data: Any, schema: dict, normalize: bool, validate: bool, path: list[str] | None = None,) -> Any:
        """
        Recursively process configuration data according to schema:
        - Apply _default for scalars if missing.
        - Apply _normalizers for scalars.
        - Checks for invalid (unknown by schema) and mandatory missing keys 
        - Recursively iterate dicts and lists.

        Params:
            data(any): config parsed as dictionar or part of it
            schema(any): schema to match with data
            normalize(bool): if normalization is required
            validate(bool): if validation is required
            path(list[str]): list used for recursion to store the path for the errors
        
        Mutates dicts/lists in-place. Returns scalar values.
        """
        # path for errors
        if path is None:
            path = []

        kind = self._node_kind(schema)

        # activate the check type
        if "_type" in schema and validate:
            self._check_type(data, schema["_type"], path)

        # -------------------------
        # SCALAR
        # -------------------------
        if kind == "scalar":

            if data is None and "_default" in schema:
                data = schema["_default"]

            # normalization: can modify the value
            if "_normalizers" in schema and normalize:
                norms = schema["_normalizers"]
                if callable(norms):
                    norms = [norms]
                for norm in norms:
                    try:
                        data = norm(data)
                    except Exception as e:
                        raise ConfigValidationError(
                            code="normalizer_failed",
                            details=str(e),
                            path=path,
                            value=data
                        ) from e

            # enum: check against allowed values
            if "_enum" in schema and data is not None and validate:
                allowed = schema["_enum"]
                if data not in allowed:
                    raise ConfigValidationError(
                        code="invalid_enum",
                        path=path,
                        value=data,
                        allowed=allowed,
                    )

            # validators
            if "_validators" in schema and validate:
                valids = schema["_validators"]
                if callable(valids):
                    valids = [valids]
                for valid in valids:
                    try:
                        # just a check: no need to mutate data
                        valid(data)
                    except Exception as e:
                        raise ConfigValidationError(
                            code="validator_failed",
                            path=path,
                            value=data,
                            details=str(e),
                        )

            return data

        # -------------------------
        # DICTIONARY
        # -------------------------
        if isinstance(data, dict):
            # ---- check unknown keys ----
            schema_keys = {k for k in schema if not k.startswith("_")}
            data_keys = set(data.keys())

            unknown = data_keys - schema_keys
            if unknown:
                raise ConfigValidationError(
                    code="unknown_keys",
                    details=f"Unknown configuration keys {unknown}",
                    path=path,
                )

            # ---- check required keys ----
            for key, field_schema in schema.items():
                if key.startswith("_"):
                    continue

                if field_schema.get("_required") and key not in data:
                    raise ConfigValidationError(
                        code="missing_required",
                        path=path + [key],
                    )
                    

            # ---- existing logic ----
            for key, field_schema in schema.items():
                if key.startswith("_"):
                    continue

                # Apply default if missing for scalar child
                if key not in data and self._node_kind(field_schema) == "scalar" and "_default" in field_schema:
                    data[key] = field_schema["_default"]

                # Recurse for present keys
                if key in data:
                    data[key] = self._process_config_recursively(
                        data[key],
                        field_schema,
                        normalize,
                        validate,
                        path + [key]
                    )

            return data

  
        # -------------------------
        # LIST
        # -------------------------
        if isinstance(data, list) and kind == "list":
            origin = get_origin(schema.get("_type"))
            args = get_args(schema.get("_type"))

            if args and args[0] is dict:
                item_schema = {
                    k: v for k, v in schema.items()
                    if not k.startswith("_")
                }
            else:
                item_type = args[0] if args else None
                item_schema = {"_type": item_type} if item_type else {}

                for meta_key in ("_enum", "_normalizers", "_validators"):
                    if meta_key in schema:
                        item_schema[meta_key] = schema[meta_key]

            for i, item in enumerate(data):
                data[i] = self._process_config_recursively(
                    item,
                    item_schema,
                    normalize,
                    validate,
                    path + [f"[{i}]"]
                )

            return data


    
    # --------------------------
    # Objectification
    # --------------------------

    def _init_from_dict(self, config_dict: dict):
        """
        Initializes the object by converting the config dictionary into attributes.
        Params:
            config_dict (dict): The configuration data as a dictionary.
        """
        try:
            for key, value in config_dict.items():

                #field_schema = self._schema.get(key, {})
                field_schema = self._schema.get(key, {}) if self._schema else {}

                # -------------------------
                # OBJECT
                # -------------------------
                if isinstance(value, dict):
                    setattr(
                        self,
                        key,
                        Config(
                            value,
                            schema=field_schema, # Use field_schema only if schema exists
                            validate=False
                        )
                    )

                # -------------------------
                # LIST
                # -------------------------
                elif isinstance(value, list):
                    processed_list = []

                    for item in value:
                        if isinstance(item, dict):
                            processed_list.append(
                                Config(
                                    item,
                                    schema=None if not self._schema else field_schema,
                                    validate=False
                                )
                            )
                        else:
                            processed_list.append(item)

                    setattr(self, key, processed_list)

                # -------------------------
                # SCALAR
                # -------------------------
                else:
                    setattr(self, key, value)

        except Exception as e:
            raise Exception(f"Cannot convert dictionary to object: {str(e)}")