"""Offline calibration validation: run Detector on labeled frames, report PASS/FAIL.

Used by the `atm validate-calibration` subcommand. Reports per-sample detection
results against expected labels, and for failures, computes RGB distance to
each color threshold and emits tuning suggestions.

Reuses `Detector.step(frame)` - does NOT reimplement color classification.
"""
from __future__ import annotations

import json
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from .config import Config


@dataclass
class SampleRecord:
    path: str
    expected: str
    detected: str | None
    confidence: float
    rgb: tuple[int, int, int] | None
    top3: list[tuple[str, float]]  # [(name, score), ...] ranked by RGB distance
    passed: bool
    note: str = ""
    error: str | None = None  # non-None if frame load failed / schema bad


@dataclass
class ValidationReport:
    records: list[SampleRecord] = field(default_factory=list)
    config_name: str = ""

    @property
    def total(self) -> int:
        return len(self.records)

    @property
    def passed(self) -> int:
        return sum(1 for r in self.records if r.passed)

    @property
    def failed(self) -> int:
        return self.total - self.passed

    @property
    def all_pass(self) -> bool:
        return self.total > 0 and self.failed == 0

    def render(self) -> str:
        lines: list[str] = []
        hdr = f"Testing {self.total} frames"
        if self.config_name:
            hdr += f" against config {self.config_name}"
        hdr += "..."
        lines.append(hdr)
        lines.append("")

        for r in self.records:
            name = Path(r.path).name or r.path
            if r.error:
                lines.append(f"  [FAIL] {name}")
                lines.append(f"         error: {r.error}")
                continue
            tag = "PASS" if r.passed else "FAIL"
            rgb_str = f"RGB {r.rgb}" if r.rgb is not None else "RGB n/a"
            detected = r.detected if r.detected is not None else "none"
            lines.append(f"  [{tag}] {name}")
            lines.append(
                f"         expected={r.expected}  detected={detected}  "
                f"(conf {r.confidence:.2f}, {rgb_str})"
            )
            if not r.passed and r.top3:
                top3_str = " ".join(f"{n}({c:.2f})" for n, c in r.top3)
                lines.append(f"         Top 3 candidates: {top3_str}")

        lines.append("")
        pct = (self.passed / self.total * 100.0) if self.total else 0.0
        lines.append(f"SUMMARY: {self.passed}/{self.total} PASS ({pct:.0f}%)")

        fails = [r for r in self.records if not r.passed]
        if fails:
            lines.append("FAILED:")
            for r in fails:
                name = Path(r.path).name or r.path
                if r.error:
                    lines.append(f"  - {name}: {r.error}")
                    continue
                detected = r.detected if r.detected is not None else "none"
                lines.append(
                    f"  - {name}: expected {r.expected}, got {detected}"
                )

            sug_lines = [
                r._suggestion  # type: ignore[attr-defined]
                for r in fails
                if getattr(r, "_suggestion", "")
            ]
            if sug_lines:
                lines.append("")
                lines.append("SUGGESTIONS:")
                for s in sug_lines:
                    lines.append(f"  - {s}")

        return "\n".join(lines)

    def __str__(self) -> str:
        return self.render()


class ValidationError(Exception):
    """Raised for missing label files or invalid schema."""


def _rgb_distance(a: tuple[int, int, int], b: tuple[int, int, int]) -> float:
    return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))


def _load_labels(label_file: Path) -> list[dict[str, Any]]:
    if not label_file.exists():
        raise ValidationError(f"label file not found: {label_file}")
    try:
        data = json.loads(label_file.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise ValidationError(f"invalid JSON in {label_file}: {exc}") from exc
    if not isinstance(data, list):
        raise ValidationError(
            f"label file must be a JSON array; got {type(data).__name__}"
        )
    return data


def validate_calibration(
    label_file: Path,
    cfg: Config,
    config_name: str = "",
) -> ValidationReport:
    """Run Detector on each labeled frame; return a ValidationReport.

    Reuses `Detector.step(frame)`. Loads frames via cv2.imread.
    Raises ValidationError if the label file is missing or malformed.
    """
    import cv2  # local import keeps module import cheap
    from .detector import Detector

    entries = _load_labels(label_file)
    report = ValidationReport(config_name=config_name)

    palette = {
        name: spec.rgb
        for name, spec in cfg.colors.items()
        if name != "background"
    }

    detector = Detector(cfg=cfg, capture=lambda: None)

    for entry in entries:
        path = str(entry.get("path", ""))
        expected = str(entry.get("expected", ""))
        note = str(entry.get("note", ""))

        if not path or not expected:
            rec = SampleRecord(
                path=path, expected=expected, detected=None, confidence=0.0,
                rgb=None, top3=[], passed=False, note=note,
                error="missing 'path' or 'expected' field",
            )
            rec._suggestion = ""  # type: ignore[attr-defined]
            report.records.append(rec)
            continue

        frame = cv2.imread(path)
        if frame is None:
            rec = SampleRecord(
                path=path, expected=expected, detected=None, confidence=0.0,
                rgb=None, top3=[], passed=False, note=note,
                error=f"cv2.imread failed for {path}",
            )
            rec._suggestion = ""  # type: ignore[attr-defined]
            report.records.append(rec)
            continue

        result = detector.step(ts=0.0, frame=frame)

        match = result.match
        if match is None:
            detected: str | None = None
            confidence = 0.0
        else:
            detected = match.name if match.name != "UNKNOWN" else None
            confidence = match.confidence

        rgb = result.rgb

        # Top 3 candidates: rank palette entries by RGB distance to observed.
        top3: list[tuple[str, float]] = []
        if rgb is not None:
            scored: list[tuple[str, float]] = []
            for name, ref in palette.items():
                scored.append((name, _rgb_distance(rgb, ref)))
            scored.sort(key=lambda t: t[1])
            top3 = [(n, 1.0 / (1.0 + d / 20.0)) for n, d in scored[:3]]

        passed = detected == expected

        rec = SampleRecord(
            path=path, expected=expected, detected=detected,
            confidence=confidence, rgb=rgb, top3=top3, passed=passed, note=note,
        )

        if not passed and rgb is not None and expected in palette:
            ref = palette[expected]
            tol = cfg.colors[expected].tolerance
            dist = _rgb_distance(rgb, ref)
            rec._suggestion = (  # type: ignore[attr-defined]
                f"{expected} praguri curente: RGB{ref} +/- {tol:.0f}. "
                f"Pixelul observat {rgb} e la distanta {dist:.1f} "
                f"-> recalibreaza cu acest sample."
            )
        else:
            rec._suggestion = ""  # type: ignore[attr-defined]

        report.records.append(rec)

    return report
