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

import configsage.common_decorators as cdec
from configsage.config_callables import wrap_name_fmt


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.
    """

    SCHEMA_META_KEYS: set = {
        "_type",
        "_items",
        "_defaults",
        "_enum",
        "_normalize",
        "_validator",
        "_required",
    }

    def __init__(
        self,
        config_src: Union[str, dict],  # Configuration file path or pre-parsed dict
        *,
        schema: dict,  # Schema definition in Python dict format
        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 not isinstance(schema, dict):
            raise TypeError("schema must be a dict (from schema.py)")

        self._schema = schema
        # get the yaml as dictionary
        config_dict = self._load_yaml_or_dict(config_src)
        # if there are missing keys but the schema has a default for them, add the key-values to the dictionary from the schema
        self._apply_defaults_missing_only(config_dict, self._schema)

        # if normalize is active then normalize the values before checking them against the allowed ones (e.g.: convert all to small-case before comparing)
        if normalize:
            self._normalize_values(config_dict, self._schema)

        if validate:            
            # check if there are unexpected/unknown keys
            self._check_unknown_keys(config_dict, self._schema)
            # check if the mandatory keys are present
            self._check_required_keys(config_dict, self._schema)
            
            # validate the values against the types
            self._type_validation(config_dict, self._schema)
            # validate the list values against the allowed enums
            self._enum_validation(config_dict, self._schema)
            
            # apply the custom validators
            #self._validate_custom(config_dict, self._schema)

        self._init_from_dict(config_dict)



    # --------------------------
    # Utilities
    # --------------------------

    @cdec.wrap_with_name(formatter=wrap_name_fmt)
    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)}")


    def _schema_describes_list(self, schema: Any) -> bool:
        """
        Checks if the given schema defines a list structure using the `_items` key.
        Params:
            schema (Any): The schema to check.
        Returns:
            bool: True if schema defines a list structure, False otherwise.
        """
        return isinstance(schema, dict) and "_items" in schema


    def _schema_is_leaf(self, schema: Any) -> bool:
        """
        Checks if the schema represents a leaf value (e.g.: a single value with a type).
        Params:
            schema (Any): The schema to check.
        Returns:
            bool: True if the schema is a leaf value, False otherwise.
        """
        return isinstance(schema, dict) and "_type" in schema


    def _is_required_field(self, field_schema: Any) -> bool:
        """
        Determines whether a field is required based on the `_required` flag.
        Params:
            field_schema (Any): The schema for the field to check.
        Returns:
            bool: True if the field is required, False otherwise.
        """
        # non-dict fields are not required by default
        if not isinstance(field_schema, dict):
            return False  
        return field_schema.get("_required", False)
        
    

    # --------------------------
    # 1) Apply Defaults (Missing Only)
    # --------------------------

    def _apply_defaults_missing_only(self, data: Any, schema: Any):
        """
        Recursively applies per-field defaults to missing keys in a dictionary or list.
        - Only sets a value if the key is missing.
        - Does not override existing YAML values.
        - mutates the dictionary in-place by changing the values rather than re-defining the dictionary var locally (no need to return data)
        Params:
            data (Any): The configuration data to update.
            schema (Any): The schema to guide the default application.
        """
        if self._schema_is_leaf(schema):
            # Apply _default for leaf fields
            if "_default" in schema and data is None:
                data = schema["_default"]
            return data

        if self._schema_describes_list(schema) and isinstance(data, list):
            for idx, item in enumerate(data):
                data[idx] = self._apply_defaults_missing_only(item, schema["_items"])
        
        if isinstance(schema, dict) and isinstance(data, dict):
            for key, field_schema in schema.items():
                if key.startswith("_"):
                    continue  # skip meta keys
                if key not in data and "_default" in field_schema:
                    data[key] = field_schema["_default"]
                if key in data:
                    data[key] = self._apply_defaults_missing_only(data[key], field_schema)

        return data




    # --------------------------
    # 2) Presence / Unknown Keys Validation
    # --------------------------

    def _check_unknown_keys(self, data: Any, schema: Any, path: str = "root"):
        """
        Recursively checks for unknown keys in the configuration data.
        Params:
            data (Any): The configuration data to validate.
            schema (Any): The schema to validate against.
            path (str): The path to the current node for error reporting.
        Raises:
            KeyError: If an unknown key is found in the data.
            TypeError: If the data type does not match the expected type.
        """
        if self._schema_is_leaf(schema):
            return

        if self._schema_describes_list(schema):
            if not isinstance(data, list):
                raise TypeError(f"{path}: expected list, got {type(data).__name__}")
            for idx, item in enumerate(data):
                self._check_unknown_keys(item, schema["_items"], f"{path}[{idx}]")
            return

        if isinstance(schema, dict):
            if not isinstance(data, dict):
                raise TypeError(f"{path}: expected dict, got {type(data).__name__}")

            schema_keys = {k for k in schema if not k.startswith("_")}
            for key in data:
                if key not in schema_keys:
                    raise KeyError(f"{path}: invalid key '{key}'")
            for key in schema_keys:
                if key in data:
                    self._check_unknown_keys(data[key], schema[key], f"{path}.{key}")

    def _check_required_keys(self, data: Any, schema: Any, path: str = "root"):
        """
        Recursively checks for required keys in the configuration data.
        Params:
            data (Any): The configuration data to validate.
            schema (Any): The schema to validate against.
            path (str): The path to the current node for error reporting.
        Raises:
            KeyError: If a required key is missing in the data.
            TypeError: If the data type does not match the expected type.
        """
        if self._schema_is_leaf(schema):
            return

        if self._schema_describes_list(schema):
            if not isinstance(data, list):
                raise TypeError(f"{path}: expected list, got {type(data).__name__}")
            for idx, item in enumerate(data):
                self._check_required_keys(item, schema["_items"], f"{path}[{idx}]")
            return

        if isinstance(schema, dict):
            if not isinstance(data, dict):
                raise TypeError(f"{path}: expected dict, got {type(data).__name__}")

            for key, field_schema in schema.items():
                if key.startswith("_"):
                    continue  # skip meta keys
                if self._is_required_field(field_schema) and key not in data:
                    raise KeyError(f"{path}: missing required key '{key}'")
                if key in data:
                    self._check_required_keys(data[key], field_schema, f"{path}.{key}")

    
    
    # --------------------------
    # 3) Value Normalization
    # --------------------------
    def _normalize_values(self, data: Any, schema: dict):
        """
        Apply the normalization functions defined in the schema to leaf values
        before performing value validation. Mutates the data in place.

        Params:
            data (Any): The configuration data to normalize.
            schema (dict): The schema that defines normalization rules for fields.
        """
        if self._schema_is_leaf(schema):
            # Normalize leaf field values if _normalize is defined in the schema
            if "_normalize" in schema:
                normalizers = schema["_normalize"]
                # Ensure _normalize is iterable
                if not isinstance(normalizers, list):
                    normalizers = [normalizers]
                
                for normalizer in normalizers:
                    if callable(normalizer):
                        #print(f"Normalizing field with value: {data}")
                        data = normalizer(data)  # Apply the normalization function
                        #print(f"Result after normalizer: {data}")
                        
                # Instead of returning the modified `data`, directly update it in place
                # and let it propagate back to the calling function.
                return data  # In-place mutation ensures the original `data` is updated
        elif self._schema_describes_list(schema) and isinstance(data, list):
            # For lists, normalize each item according to the item schema
            item_schema = schema.get("_items", {})
            for idx, item in enumerate(data):
                data[idx] = self._normalize_values(item, item_schema)  # Modify in-place
            return data  # No need to return anything, mutate in place

        elif isinstance(schema, dict) and isinstance(data, dict):
            # For dictionary-type fields, normalize each field according to the schema
            for key, field_schema in schema.items():
                # Skip meta keys
                if key.startswith("_"):
                    continue
                if key in data:
                    data[key] = self._normalize_values(data[key], field_schema)  # Modify in-place
            return data  # No need to return anything, mutate in place
            
    
    # --------------------------
    # 4) Type Validation
    # --------------------------
    #@cdec.wrap_with_name(formatter=wrap_name_fmt)
    def _type_validation(self, data: Any, schema: dict):
        """
        Validates the type of each value in the configuration data against the schema's '_type' field.
        If a field is missing or None, and it is required, the function should raise an exception.

        Params:
            data (Any): The configuration data to validate.
            schema (dict): The schema to validate against.
        """
        # for the leaf item check the type
        if self._schema_is_leaf(schema):
            # Type check only if _type is defined
            if "_type" in schema:
                expected_type = schema["_type"]
                if data is None:
                    return  # skip type validation
                elif not isinstance(data, expected_type):
                    raise TypeError(f"{schema}: expected type {expected_type}, but got {type(data).__name__}")
            return

        # for the list, checks the type for all the items in the list (by recursion)
        if self._schema_describes_list(schema) and isinstance(data, list):
            item_schema = schema.get("_items", {})
            for idx, item in enumerate(data):
                self._type_validation(item, item_schema)
            return

        # if the item is a dictionary recursively check all the nested items
        if isinstance(schema, dict) and isinstance(data, dict):
            for key, field_schema in schema.items():
                if key.startswith("_"):  # Skip meta keys like _type
                    continue
                if key in data:
                    self._type_validation(data[key], field_schema)
            return
    

    # --------------------------
    # 5) Enum Validation
    # --------------------------
    #@cdec.wrap_with_name(formatter=wrap_name_fmt)
    def _enum_validation(self, data: Any, schema: dict):
        """
        Validates the value of a field against the allowed values in the '_enum' attribute of the schema.
        Params:
            data (Any): The configuration data to validate.
            schema (dict): The schema to validate against.
        Raises:
            ValueError if the value is not in the corresponding '_enum' list
        """
        # in case of leaf value, evaluate
        if self._schema_is_leaf(schema):
            # Check for _enum in the leaf schema
            if "_enum" in schema:
                enum_values = schema["_enum"]
                if isinstance(data,str):
                    data = data.strip().lower()
                # Normalize each enum value (strip and lower)
                normalized_enum_values = {value.strip().lower() if isinstance(value, str) else value for value in enum_values}
                if data not in enum_values:
                    raise ValueError(f"{data}: value not in enum {enum_values}")
            return

        # in case of list iterate recursively
        if self._schema_describes_list(schema) and isinstance(data, list):
            item_schema = schema.get("_items", {})
            for idx, item in enumerate(data):
                self._enum_validation(item, item_schema)
            return

        # in case of dictionary iterate recursively
        if isinstance(schema, dict) and isinstance(data, dict):
            for key, field_schema in schema.items():
                if key.startswith("_"):  # Skip meta keys like _enum
                    continue
                if key in data:
                    self._enum_validation(data[key], field_schema)
            return

    
    # --------------------------
    # 6) Apply Custom Validators
    # --------------------------
    def _validate_custom(self, data: Any, schema: Any, path: str = "root"):
        pass
    # def _validate_custom(self, data: Any, schema: Any, path: str = "root"):
    #     if self._schema_describes_list(schema):
    #         for idx, item in enumerate(data):
    #             self._validate_custom(item, schema["_items"], f"{path}[{idx}]")
    #         return

    #     if isinstance(schema, dict) and "_type" in schema:
    #         validators = schema.get("_validator")
    #         if validators:
    #             if not isinstance(validators, (list, tuple)):
    #                 validators = [validators]
    #             for v in validators:
    #                 self._run_validator(v, data, path)
    #         return

    #     if isinstance(schema, dict):
    #         for key in self._declared_keys(schema):
    #             if key in data:
    #                 self._validate_custom(data[key], schema[key], f"{path}.{key}")

    # @cdec.wrap_with_name(formatter=wrap_name_fmt)
    # def _run_validator(self, validator: Any, value: Any, path: str):
    #     if not callable(validator):
    #         raise TypeError(f"{path}: validator {validator!r} is not callable")
    #     try:
    #         validator(value)
    #     except Exception as e:
    #         raise Exception(f"{path}: custom validator failed: {e}") from e

    # --------------------------
    # 7) 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():
                if isinstance(value, dict):
                    # Use the field's schema for this key, not the root schema
                    field_schema = self._schema.get(key, {})
                    setattr(self, key, Config(value, schema=field_schema, validate=False))
                elif isinstance(value, list):
                    processed_list = []
                    # If schema defines _items, use that as item schema
                    item_schema = self._schema.get(key, {}).get("_items", {})
                    for item in value:
                        if isinstance(item, dict):
                            processed_list.append(Config(item, schema=item_schema, validate=False))
                        else:
                            processed_list.append(item)
                    setattr(self, key, processed_list)
                else:
                    setattr(self, key, value)
        except Exception as e:
            raise Exception(f"Cannot convert dictionary to object: {str(e)}")



    # --------------------------
    # 6) Utilities for Callers
    # --------------------------

    def to_dict(self) -> dict:
        """
        Converts the Config object (and nested children) back into plain dicts/lists/scalars.

        Returns:
            dict: The configuration data as a plain dictionary.
        """
        out: dict[str, Any] = {}
        for attr, val in self.__dict__.items():
            if attr.startswith("_"):
                continue
            out[attr] = self._to_plain(val)
        return out

    def _to_plain(self, val: Any) -> Any:
        """
        Converts a value (including nested objects) into its plain form.

        Params:
            val (Any): The value to convert.

        Returns:
            Any: The plain form of the value (dicts are recursively converted).
        """
        if isinstance(val, Config):
            return val.to_dict()
        if isinstance(val, list):
            return [self._to_plain(x) for x in val]
        return val
