"""Load and validate YAML eval suite files into EvalSuite objects."""

from __future__ import annotations

from pathlib import Path
from typing import Any

import yaml

from hatchdx.eval.models import (
    VALID_ASSERTION_TYPES,
    EvalAssertion,
    EvalCase,
    EvalError,
    EvalSuite,
)


REQUIRED_EVAL_FIELDS = {"name", "tool", "input", "assertions"}
REQUIRED_SUITE_FIELDS = {"suite", "evals"}


def load_suites(directory: Path) -> list[EvalSuite]:
    """Load all *.yaml files from *directory* and return a list of EvalSuites.

    Raises EvalError with a clear message when a file is malformed.
    """
    directory = Path(directory)
    if not directory.is_dir():
        raise EvalError(f"Evals directory does not exist: {directory}")

    yaml_files = sorted(directory.glob("*.yaml"))
    if not yaml_files:
        return []

    suites: list[EvalSuite] = []
    for filepath in yaml_files:
        suite = _load_suite_file(filepath)
        if suite is not None:
            suites.append(suite)
    return suites


def _load_suite_file(filepath: Path) -> EvalSuite | None:
    """Parse a single YAML eval suite file into an EvalSuite."""
    try:
        raw = yaml.safe_load(filepath.read_text())
    except yaml.YAMLError as exc:
        raise EvalError(f"Invalid YAML in {filepath.name}: {exc}") from exc
    except (UnicodeDecodeError, PermissionError, OSError) as exc:
        raise EvalError(f"Cannot read {filepath.name}: {exc}") from exc

    # Empty file
    if raw is None:
        return None

    if not isinstance(raw, dict):
        raise EvalError(
            f"{filepath.name}: expected a YAML mapping at the top level, "
            f"got {type(raw).__name__}.\n"
            "  Example structure:\n"
            '    suite: "my eval suite"\n'
            '    description: "Tests for my tools"\n'
            "    evals:\n"
            '      - name: "check output"\n'
            '        tool: "my_tool"\n'
            "        input: {}\n"
            "        assertions:\n"
            "          - type: schema\n"
            "            schema: { type: object }"
        )

    missing = REQUIRED_SUITE_FIELDS - raw.keys()
    if missing:
        raise EvalError(
            f"{filepath.name}: missing required top-level fields: "
            f"{', '.join(sorted(missing))}"
        )

    suite_name = raw["suite"]
    if not isinstance(suite_name, str) or not suite_name.strip():
        raise EvalError(f"{filepath.name}: 'suite' must be a non-empty string")

    description = raw.get("description", "")
    if not isinstance(description, str):
        raise EvalError(f"{filepath.name}: 'description' must be a string")

    evals_raw = raw["evals"]
    if not isinstance(evals_raw, list):
        raise EvalError(
            f"{filepath.name}: 'evals' must be a list, got {type(evals_raw).__name__}"
        )

    eval_cases = [_parse_eval_case(entry, filepath) for entry in evals_raw]

    return EvalSuite(
        name=suite_name.strip(),
        description=description.strip(),
        evals=eval_cases,
    )


def _parse_eval_case(entry: Any, filepath: Path) -> EvalCase:
    """Validate and convert a raw dict into an EvalCase."""
    if not isinstance(entry, dict):
        raise EvalError(
            f"{filepath.name}: each eval must be a mapping, got {type(entry).__name__}"
        )

    missing = REQUIRED_EVAL_FIELDS - entry.keys()
    if missing:
        eval_name = entry.get("name", "<unnamed>")
        raise EvalError(
            f"{filepath.name}: eval '{eval_name}' is missing fields: "
            f"{', '.join(sorted(missing))}"
        )

    name = entry["name"]
    if not isinstance(name, str) or not name.strip():
        raise EvalError(f"{filepath.name}: eval 'name' must be a non-empty string")

    tool = entry["tool"]
    if not isinstance(tool, str) or not tool.strip():
        raise EvalError(
            f"{filepath.name}: eval '{name}' has invalid 'tool' — must be a non-empty string"
        )

    input_data = entry["input"]
    if input_data is None:
        input_data = {}
    if not isinstance(input_data, dict):
        raise EvalError(
            f"{filepath.name}: eval '{name}' input must be a mapping, "
            f"got {type(input_data).__name__}"
        )

    assertions_raw = entry["assertions"]
    if not isinstance(assertions_raw, list):
        raise EvalError(
            f"{filepath.name}: eval '{name}' assertions must be a list, "
            f"got {type(assertions_raw).__name__}"
        )

    if not assertions_raw:
        raise EvalError(
            f"{filepath.name}: eval '{name}' must have at least one assertion"
        )

    assertions = [
        _parse_assertion(a, name, filepath) for a in assertions_raw
    ]

    return EvalCase(
        name=name.strip(),
        tool=tool.strip(),
        input=input_data,
        assertions=assertions,
    )


def _parse_assertion(raw: Any, eval_name: str, filepath: Path) -> EvalAssertion:
    """Validate and convert a raw dict into an EvalAssertion."""
    if not isinstance(raw, dict):
        raise EvalError(
            f"{filepath.name}: eval '{eval_name}' — each assertion must be a mapping, "
            f"got {type(raw).__name__}"
        )

    assertion_type = raw.get("type")
    if not assertion_type or assertion_type not in VALID_ASSERTION_TYPES:
        raise EvalError(
            f"{filepath.name}: eval '{eval_name}' — invalid assertion type: "
            f"'{assertion_type}'. Valid types: {', '.join(sorted(VALID_ASSERTION_TYPES))}"
        )

    # Type-specific validation
    if assertion_type == "schema":
        if "schema" not in raw or not isinstance(raw.get("schema"), dict):
            raise EvalError(
                f"{filepath.name}: eval '{eval_name}' — 'schema' assertion requires "
                "a 'schema' field with a JSON Schema object"
            )

    if assertion_type == "range":
        if "path" not in raw:
            raise EvalError(
                f"{filepath.name}: eval '{eval_name}' — 'range' assertion requires a 'path' field"
            )
        if "min" not in raw and "max" not in raw:
            raise EvalError(
                f"{filepath.name}: eval '{eval_name}' — 'range' assertion requires "
                "at least one of 'min' or 'max'"
            )

    if assertion_type == "length":
        if "path" not in raw:
            raise EvalError(
                f"{filepath.name}: eval '{eval_name}' — 'length' assertion requires a 'path' field"
            )
        if "expected" not in raw:
            raise EvalError(
                f"{filepath.name}: eval '{eval_name}' — 'length' assertion requires "
                "an 'expected' field"
            )

    if assertion_type == "contains":
        if "path" not in raw:
            raise EvalError(
                f"{filepath.name}: eval '{eval_name}' — 'contains' assertion requires a 'path' field"
            )
        if "expected" not in raw:
            raise EvalError(
                f"{filepath.name}: eval '{eval_name}' — 'contains' assertion requires "
                "an 'expected' field"
            )

    if assertion_type == "golden_file":
        # golden_file is optional — auto-generated from suite/eval name if not provided
        pass

    return EvalAssertion(
        type=assertion_type,
        path=raw.get("path"),
        schema=raw.get("schema"),
        min=_to_float_or_none(raw.get("min")),
        max=_to_float_or_none(raw.get("max")),
        expected=raw.get("expected"),
        golden_file=raw.get("golden_file"),
    )


def _to_float_or_none(value: Any) -> float | None:
    """Convert a numeric value to float, or return None."""
    if value is None:
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None
