"""
fw_server/calibration.py — Ground-truth calibration pipeline.

Addresses BS-21 (Empty kaynak_metinler) and BS-22 (No ground truth) from
WATERFALL_PLAN.md §12.  Loads source texts from kaynak_metinler/, runs them
through the dusun() pipeline, and compares against curated expected scores.

Constraints:
  AX64: Source texts are immutable — read-only access.
  AX65: Every formalization paired with a reference.
  KV₄:  0 < composite < 1.
  T17:  Coverage ≤ 6/7.
  No external dependencies (stdlib only; Phase 4 SKIPPED).
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)

# ── Constants ──────────────────────────────────────────────────────────

# Default location of source texts — prefer installed package, fallback to project root
try:
    import kaynak_metinler
    _DEFAULT_KAYNAK_DIR = Path(kaynak_metinler.__file__).resolve().parent
except ImportError:
    _DEFAULT_KAYNAK_DIR = Path(__file__).resolve().parent.parent / "kaynak_metinler"

# Required files in each source text directory (AX65 compliance)
_REQUIRED_FILES = ("original.md", "axiom_map.json", "expected_scores.json")

# Epistemic grade ordering (ascending)
_GRADE_ORDER = ("Tasavvur", "Tasdik", "İlmelyakîn")


# ── Data classes ───────────────────────────────────────────────────────

@dataclass
class SourceText:
    """A curated source text entry from kaynak_metinler/."""

    text_id: str
    original_text: str
    axiom_map: Dict[str, Any]
    expected_scores: Dict[str, Any]
    text_hash: str = ""  # SHA-256 of original.md for AX64 immutability checking

    def __post_init__(self) -> None:
        if not self.text_hash:
            self.text_hash = hashlib.sha256(
                self.original_text.encode("utf-8")
            ).hexdigest()


@dataclass
class LensCalibration:
    """Per-lens calibration result."""

    lens_id: str
    actual_score: float
    expected_min: float
    expected_max: float
    in_range: bool = False
    delta: float = 0.0  # signed distance from nearest range boundary

    def __post_init__(self) -> None:
        self.in_range = self.expected_min <= self.actual_score <= self.expected_max
        if self.in_range:
            self.delta = 0.0
        elif self.actual_score < self.expected_min:
            self.delta = self.actual_score - self.expected_min  # negative
        else:
            self.delta = self.actual_score - self.expected_max  # positive


@dataclass
class TextCalibration:
    """Calibration result for a single source text."""

    text_id: str
    lens_results: List[LensCalibration] = field(default_factory=list)
    composite_actual: float = 0.0
    composite_in_range: bool = False
    path_actual: str = ""
    path_expected: str = ""
    path_match: bool = False
    grade_actual: str = ""
    grade_expected_min: str = ""
    grade_meets_min: bool = False
    patterns_actual: List[str] = field(default_factory=list)
    patterns_expected_min: List[str] = field(default_factory=list)
    patterns_detected: List[str] = field(default_factory=list)
    pattern_coverage: float = 0.0
    error: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        d = asdict(self)
        d["lens_results"] = [asdict(lr) for lr in self.lens_results]
        return d


@dataclass
class CalibrationResult:
    """Aggregate calibration result across all source texts.

    Per WATERFALL_PLAN §12.3:
    - Per-lens accuracy (score within expected range)
    - Grade accuracy (grade matches expected)
    - Pattern detection accuracy
    - Composite score calibration
    - Recommended parameter adjustments
    """

    text_results: List[TextCalibration] = field(default_factory=list)
    lens_accuracy: float = 0.0           # fraction of lens scores in range
    grade_accuracy: float = 0.0          # fraction of grades meeting minimum
    pattern_accuracy: float = 0.0        # average pattern coverage
    composite_accuracy: float = 0.0      # fraction of composites in range
    path_accuracy: float = 0.0           # fraction of correct path classifications
    total_texts: int = 0
    recommended_adjustments: List[str] = field(default_factory=list)
    error: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        d = asdict(self)
        d["text_results"] = [tr.to_dict() for tr in self.text_results]
        return d


# ── Loader ─────────────────────────────────────────────────────────────

def load_source_texts(
    kaynak_dir: Optional[Path] = None,
) -> List[SourceText]:
    """Load all source texts from kaynak_metinler/.

    Each subdirectory must contain:
      - original.md          (AX64: immutable source text)
      - axiom_map.json       (AX65: axiom ↔ passage references)
      - expected_scores.json (ground-truth expected ranges)

    Returns a list of SourceText objects.  Skips entries with missing files
    and logs warnings.
    """
    base = kaynak_dir or _DEFAULT_KAYNAK_DIR
    if not base.is_dir():
        logger.warning("kaynak_metinler directory not found: %s", base)
        return []

    texts: List[SourceText] = []
    for entry in sorted(base.iterdir()):
        if not entry.is_dir():
            continue

        # Check required files
        missing = [f for f in _REQUIRED_FILES if not (entry / f).is_file()]
        if missing:
            logger.warning(
                "Skipping %s — missing: %s", entry.name, ", ".join(missing)
            )
            continue

        try:
            original = (entry / "original.md").read_text(encoding="utf-8")
            axiom_map = json.loads(
                (entry / "axiom_map.json").read_text(encoding="utf-8")
            )
            expected = json.loads(
                (entry / "expected_scores.json").read_text(encoding="utf-8")
            )
            texts.append(SourceText(
                text_id=entry.name,
                original_text=original,
                axiom_map=axiom_map,
                expected_scores=expected,
            ))
        except Exception as exc:
            logger.warning("Error loading %s: %s", entry.name, exc)

    return texts


def verify_immutability(
    texts: List[SourceText],
    kaynak_dir: Optional[Path] = None,
) -> List[Tuple[str, bool]]:
    """Verify AX64: source texts have not been modified since loading.

    Recomputes SHA-256 of each original.md and compares with stored hash.
    Returns list of (text_id, is_immutable) tuples.
    """
    base = kaynak_dir or _DEFAULT_KAYNAK_DIR
    results: List[Tuple[str, bool]] = []
    for st in texts:
        path = base / st.text_id / "original.md"
        if not path.exists():
            results.append((st.text_id, False))
            continue
        current_hash = hashlib.sha256(
            path.read_text(encoding="utf-8").encode("utf-8")
        ).hexdigest()
        results.append((st.text_id, current_hash == st.text_hash))
    return results


def verify_axiom_maps(texts: List[SourceText]) -> List[Tuple[str, bool, int]]:
    """Verify AX65: every axiom map has at least one grounded axiom.

    Returns list of (text_id, has_axioms, axiom_count) tuples.
    """
    results: List[Tuple[str, bool, int]] = []
    for st in texts:
        axioms = st.axiom_map.get("axioms_grounded", [])
        results.append((st.text_id, len(axioms) > 0, len(axioms)))
    return results


# ── Grade comparison ───────────────────────────────────────────────────

def _grade_rank(grade: str) -> int:
    """Return numeric rank for grade comparison.  Higher = better."""
    for i, g in enumerate(_GRADE_ORDER):
        if g == grade:
            return i
    return 0  # unknown → Tasavvur level


def _grade_meets_min(actual: str, minimum: str) -> bool:
    """Check that actual grade is ≥ the minimum expected."""
    return _grade_rank(actual) >= _grade_rank(minimum)


# ── Core calibration ──────────────────────────────────────────────────

def _calibrate_single(
    source: SourceText,
    dusun_result: Dict[str, Any],
) -> TextCalibration:
    """Compare a single dusun() result against expected scores."""

    tc = TextCalibration(text_id=source.text_id)
    expected = source.expected_scores

    # ── Lens scores ──
    actual_lens = dusun_result.get("lens_scores", {})
    expected_lens = expected.get("expected_scores", {})

    for lens_id, bounds in expected_lens.items():
        actual_score = actual_lens.get(lens_id, 0.0)
        if isinstance(actual_score, (int, float)):
            lc = LensCalibration(
                lens_id=lens_id,
                actual_score=float(actual_score),
                expected_min=bounds.get("min", 0.0),
                expected_max=bounds.get("max", 1.0),
            )
            tc.lens_results.append(lc)

    # ── Composite ──
    tc.composite_actual = dusun_result.get("composite_score", 0.0)
    composite_bounds = expected.get("expected_composite", {})
    if composite_bounds:
        c_min = composite_bounds.get("min", 0.0)
        c_max = composite_bounds.get("max", 1.0)
        tc.composite_in_range = c_min <= tc.composite_actual <= c_max

    # ── Path ──
    tc.path_actual = dusun_result.get("path", "P0")
    tc.path_expected = expected.get("expected_path", "")
    tc.path_match = tc.path_actual == tc.path_expected

    # ── Grade ──
    tc.grade_actual = dusun_result.get("grade", "Tasavvur")
    tc.grade_expected_min = expected.get("expected_grade_min", "Tasavvur")
    tc.grade_meets_min = _grade_meets_min(tc.grade_actual, tc.grade_expected_min)

    # ── Patterns ──
    tc.patterns_actual = dusun_result.get("patterns_applied", [])
    tc.patterns_expected_min = expected.get("expected_patterns_min", [])
    if tc.patterns_expected_min:
        tc.patterns_detected = [
            p for p in tc.patterns_expected_min if p in tc.patterns_actual
        ]
        tc.pattern_coverage = len(tc.patterns_detected) / len(
            tc.patterns_expected_min
        )
    else:
        tc.pattern_coverage = 1.0  # no expectations → full match

    return tc


def calibrate_pipeline(
    source_texts: Optional[List[SourceText]] = None,
    kaynak_dir: Optional[Path] = None,
) -> CalibrationResult:
    """Run the full calibration pipeline on ground-truth texts.

    Per WATERFALL_PLAN §12.3:
    1. Load source texts (if not provided)
    2. Run each through dusun()
    3. Compare against expected ranges
    4. Compute aggregate accuracy metrics
    5. Generate recommended adjustments

    Args:
        source_texts: Pre-loaded texts.  If None, loads from kaynak_dir.
        kaynak_dir: Override directory.  Defaults to kaynak_metinler/.

    Returns:
        CalibrationResult with per-text and aggregate metrics.
    """
    from fw_server.dusun import dusun

    if source_texts is None:
        source_texts = load_source_texts(kaynak_dir)

    if not source_texts:
        return CalibrationResult(
            error="No source texts found in kaynak_metinler/",
        )

    text_results: List[TextCalibration] = []
    total_lens_checks = 0
    lens_in_range = 0
    grade_passes = 0
    composite_passes = 0
    path_passes = 0
    pattern_coverages: List[float] = []

    for st in source_texts:
        try:
            result = dusun(st.original_text, mode="analyze")
            tc = _calibrate_single(st, result)
        except Exception as exc:
            tc = TextCalibration(
                text_id=st.text_id,
                error=str(exc),
            )
            logger.warning("Calibration error for %s: %s", st.text_id, exc)

        text_results.append(tc)

        # Aggregate counts
        for lc in tc.lens_results:
            total_lens_checks += 1
            if lc.in_range:
                lens_in_range += 1
        if tc.grade_meets_min:
            grade_passes += 1
        if tc.composite_in_range:
            composite_passes += 1
        if tc.path_match:
            path_passes += 1
        pattern_coverages.append(tc.pattern_coverage)

    n = len(source_texts)
    lens_accuracy = lens_in_range / max(total_lens_checks, 1)
    grade_accuracy = grade_passes / n
    composite_accuracy = composite_passes / n
    path_accuracy = path_passes / n
    pattern_accuracy = (
        sum(pattern_coverages) / len(pattern_coverages)
        if pattern_coverages
        else 0.0
    )

    # ── Recommended adjustments ──
    adjustments: List[str] = []

    if lens_accuracy < 0.5:
        adjustments.append(
            "Low lens accuracy (%.0f%%) — consider widening expected_scores "
            "ranges or tuning lens extractors." % (lens_accuracy * 100)
        )

    if grade_accuracy < 0.5:
        adjustments.append(
            "Low grade accuracy (%.0f%%) — review adaptive grade thresholds "
            "in grade_map.py." % (grade_accuracy * 100)
        )

    if pattern_accuracy < 0.5:
        adjustments.append(
            "Low pattern detection (%.0f%%) — check _detect_patterns "
            "vocabulary triggers in dusun.py." % (pattern_accuracy * 100)
        )

    if path_accuracy < 0.5:
        adjustments.append(
            "Low path accuracy (%.0f%%) — review _auto_classify "
            "thresholds in dusun.py." % (path_accuracy * 100)
        )

    # Lens-level detail: identify consistently-low/high lenses
    from collections import defaultdict
    lens_deltas: Dict[str, List[float]] = defaultdict(list)
    for tc in text_results:
        for lc in tc.lens_results:
            if not lc.in_range:
                lens_deltas[lc.lens_id].append(lc.delta)

    for lid, deltas in lens_deltas.items():
        if len(deltas) >= 2:
            avg = sum(deltas) / len(deltas)
            if avg < 0:
                adjustments.append(
                    f"Lens '{lid}' consistently below range (avg Δ={avg:.3f}) — "
                    f"consider lowering expected_min or tuning extractor."
                )
            else:
                adjustments.append(
                    f"Lens '{lid}' consistently above range (avg Δ={avg:+.3f}) — "
                    f"consider raising expected_max or adding score cap."
                )

    return CalibrationResult(
        text_results=text_results,
        lens_accuracy=round(lens_accuracy, 4),
        grade_accuracy=round(grade_accuracy, 4),
        pattern_accuracy=round(pattern_accuracy, 4),
        composite_accuracy=round(composite_accuracy, 4),
        path_accuracy=round(path_accuracy, 4),
        total_texts=n,
        recommended_adjustments=adjustments,
    )
