from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Iterable
from ._exceptions import ConfigValidationError
from .logger import get_logger
logger = get_logger(name="ConfigValidator", logging.ERROR)
PathStep = str | int
ShouldFlagNone = Callable[[str, Any, dict], bool]
@dataclass(frozen=True, slots=True)
class ValidationIssue:
    path: str
    message: str
    code: str
    def render(self) -> str:
        return f"{self.path or '<root>'}: {self.message}"
def load_config(path: str | Path) -> dict:
    p = Path(path)
    if not p.is_file():
        raise FileNotFoundError(f"Config file not found at {p}")
    with p.open("r", encoding="utf-8") as f:
        try:
            cfg = json.load(f)
        except json.JSONDecodeError as e:
            raise ValueError(f"Invalid JSON in config file {p}: {e}") from e
    if not isinstance(cfg, dict):
        raise TypeError(f"Top-level config must be a JSON object, got {type(cfg).__name__}")
    return cfg
def parse_path(path: str) -> list[PathStep]:
    parts: list[PathStep] = []
    if not path:
        return parts
    for chunk in path.split("."):
        match = re.match(r"([^\[]+)(.*)", chunk)
        if not match:
            continue
        key, rest = match.groups()
        parts.append(key)
        for idx_str in re.findall(r"\[(\d+)\]", rest):
            parts.append(int(idx_str))
    return parts
def format_path(path: str) -> str:
    parts = parse_path(path)
    formatted: list[str] = []
    for part in parts:
        formatted.append(f"index {part}" if isinstance(part, int) else part)
    return " → ".join(formatted)
def get_by_path(obj: Any, path: str, default: Any = None) -> Any:
    current = obj
    for step in parse_path(path):
        if isinstance(step, int):
            if not isinstance(current, list) or not (0 <= step < len(current)):
                return default
            current = current[step]
        else:
            if not isinstance(current, dict) or step not in current:
                return default
            current = current[step]
    return current
def set_by_path(obj: Any, path: str, value: Any) -> None:
    steps = parse_path(path)
    if not steps:
        raise ValueError("path cannot be empty")
    current = obj
    for step in steps[:-1]:
        if isinstance(step, int):
            if not isinstance(current, list) or not (0 <= step < len(current)):
                raise KeyError(path)
            current = current[step]
        else:
            if not isinstance(current, dict) or step not in current:
                raise KeyError(path)
            current = current[step]
    last = steps[-1]
    if isinstance(last, int):
        if not isinstance(current, list) or not (0 <= last < len(current)):
            raise KeyError(path)
        current[last] = value
    else:
        if not isinstance(current, dict):
            raise KeyError(path)
        current[last] = value
def none_check(obj: Any, path: str = "") -> list[str]:
    none_paths: list[str] = []
    if isinstance(obj, dict):
        for key, value in obj.items():
            new_path = f"{path}.{key}" if path else str(key)
            none_paths.extend(none_check(value, new_path))
    elif isinstance(obj, list):
        for index, value in enumerate(obj):
            new_path = f"{path}[{index}]"
            none_paths.extend(none_check(value, new_path))
    elif obj is None:
        none_paths.append(path)
    return none_paths
def _type_name(schema: Any) -> str:
    if isinstance(schema, tuple) and all(isinstance(t, type) for t in schema):
        return " | ".join(t.__name__ for t in schema)
    if isinstance(schema, type):
        return schema.__name__
    return repr(schema)
def type_check(obj: Any, schema: Any, path: str = "") -> list[ValidationIssue]:
    issues: list[ValidationIssue] = []
    if isinstance(schema, tuple) and all(isinstance(t, type) for t in schema):
        if not isinstance(obj, schema):
            issues.append(ValidationIssue(path or "<root>", f"expected {_type_name(schema)}, got {type(obj).__name__}", "type_mismatch"))
        return issues
    if isinstance(schema, type):
        if not isinstance(obj, schema):
            issues.append(ValidationIssue(path or "<root>", f"expected {schema.__name__}, got {type(obj).__name__}", "type_mismatch"))
        return issues
    if isinstance(schema, dict):
        if not isinstance(obj, dict):
            issues.append(ValidationIssue(path or "<root>", f"expected dict, got {type(obj).__name__}", "type_mismatch"))
            return issues
        for key, subschema in schema.items():
            new_path = f"{path}.{key}" if path else str(key)
            if key not in obj:
                issues.append(ValidationIssue(new_path, "is missing", "missing"))
                continue
            issues.extend(type_check(obj[key], subschema, new_path))
        return issues
    if isinstance(schema, list):
        if len(schema) != 1:
            issues.append(ValidationIssue(path or "<root>", f"list schema must have exactly one element, got {len(schema)}", "invalid_schema"))
            return issues
        if not isinstance(obj, list):
            issues.append(ValidationIssue(path or "<root>", f"expected list, got {type(obj).__name__}", "type_mismatch"))
            return issues
        subschema = schema[0]
        for idx, item in enumerate(obj):
            issues.extend(type_check(item, subschema, f"{path}[{idx}]"))
        return issues
    issues.append(ValidationIssue(path or "<root>", f"unsupported schema type {schema!r}", "invalid_schema"))
    return issues
def apply_defaults(cfg: dict, defaults: dict) -> dict:
    def merge(base: Any, default: Any) -> Any:
        if isinstance(base, dict) and isinstance(default, dict):
            merged = dict(base)
            for key, default_value in default.items():
                if key not in merged:
                    merged[key] = default_value
                else:
                    merged[key] = merge(merged[key], default_value)
            return merged
        return base
    return merge(cfg, defaults)
def config_checker(
    cfg: dict,
    schema: dict,
    *,
    should_flag_none: ShouldFlagNone | None = None,
    log_issues: bool = False,
    raise_on_error: bool = True
) -> list[ValidationIssue]:
    if not isinstance(cfg, dict):
        raise TypeError("cfg must be a dict")
    if not isinstance(schema, dict):
        raise TypeError("schema must be a dict")
    none_values = none_check(cfg)
    type_issues = type_check(cfg, schema)
    if should_flag_none is None:
        should_flag_none = lambda path, value, full_cfg: True
    issues: list[ValidationIssue] = list(type_issues)
    for path in none_values:
        value = get_by_path(cfg, path, default=None)
        if should_flag_none(path, value, cfg):
            issues.append(ValidationIssue(path or "<root>", "has value None", "none_value"))
    if issues:
        if log_issues:
            for issue in issues:
                logger.error(issue.render())
        if raise_on_error:
            raise ConfigValidationError(
                "Config validation failed:\\n" + "\\n".join(issue.render() for issue in issues)
            )
    else:
        logger.info("Config validation passed.")
    return issues