"""
tests/test_domain_accuracy.py — Domain Accuracy Test Suite

Validates that the framework's analytical engine produces correct results
on the actual source texts it was designed to analyze. Addresses the class
of failure exposed by v2.5.0: functional tests all passed while the engine
scored 0.0 on critical lenses for theological texts.

Three tiers:
  Tier 1 — Vocabulary Coverage Guards (adapters.py, dusun.py)
  Tier 2 — Structural Invariant Tests (framework constraints KV₄, T17, AX56, AX52)
  Tier 3 — Grade Reachability & Calibration (grade_map.py, bileshke)

Ground truth: kaynak_metinler/ source texts + expected_scores.json.

Design:
  KV₇: Each test is independent — no shared mutable state.
  AX64: Source texts are read-only; tests never modify them.
  T15: Structural assertions before detail assertions.
"""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any, Dict, List, Tuple

import pytest

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_KAYNAK_DIR = Path(__file__).resolve().parent.parent / "kaynak_metinler"

_LENS_IDS = (
    "Ontoloji",
    "Mereoloji",
    "FOL",
    "Bayes",
    "OyunTeorisi",
    "KategoriTeorisi",
    "Topoloji + Holografik",
)

_GRADE_ORDER = {"Tasavvur": 0, "Tasdik": 1, "İlmelyakîn": 2, "Hakkalyakîn": 3}


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


def _load_source_text(text_id: str) -> Tuple[str, Dict[str, Any]]:
    """Load a source text and its expected_scores.json."""
    d = _KAYNAK_DIR / text_id
    text = (d / "original.md").read_text(encoding="utf-8")
    expected = json.loads((d / "expected_scores.json").read_text(encoding="utf-8"))
    return text, expected


def _all_text_ids() -> List[str]:
    """Return sorted list of text IDs in kaynak_metinler/."""
    return sorted(
        d.name for d in _KAYNAK_DIR.iterdir()
        if d.is_dir() and (d / "original.md").exists()
    )


@pytest.fixture(params=_all_text_ids(), scope="module")
def source_text(request):
    """Parametrized fixture yielding (text_id, text_content, expected_scores)."""
    text_id = request.param
    text, expected = _load_source_text(text_id)
    return text_id, text, expected


# ═══════════════════════════════════════════════════════════════════════
# TIER 1: VOCABULARY COVERAGE GUARDS
# ═══════════════════════════════════════════════════════════════════════


class TestTermFrozensetSizes:
    """Regression guard: frozenset sizes must never decrease."""

    # These are the MINIMUM sizes after v2.5.0 enrichment.
    # Future additions are welcome; removals break this guard.
    _MINIMUM_SIZES = {
        "_ONTOLOJI_TERMS": 35,
        "_MEREOLOJI_TERMS": 39,
        "_BAYES_TERMS": 30,
        "_OYUN_TERMS": 18,
        "_KATEGORI_TERMS": 14,
        "_HOLOGRAFIK_TERMS": 29,
    }

    @pytest.mark.parametrize("name,min_size", list(_MINIMUM_SIZES.items()))
    def test_frozenset_minimum_size(self, name, min_size):
        """Each term frozenset must contain at least the v2.5.0 baseline count."""
        from fw_server import adapters

        terms = getattr(adapters, name)
        assert isinstance(terms, frozenset), f"{name} is not a frozenset"
        assert len(terms) >= min_size, (
            f"{name} shrank: expected >= {min_size}, got {len(terms)}"
        )


class TestTermMatchesOnSourceTexts:
    """Verify that enriched frozensets actually match terms in source texts."""

    @pytest.fixture(autouse=True)
    def _load_helpers(self):
        from fw_server.adapters import (
            _count_text_matches,
            _ONTOLOJI_TERMS,
            _MEREOLOJI_TERMS,
            _BAYES_TERMS,
            _OYUN_TERMS,
            _KATEGORI_TERMS,
            _HOLOGRAFIK_TERMS,
        )
        self.count = _count_text_matches
        self.term_sets = {
            "Ontoloji": _ONTOLOJI_TERMS,
            "Mereoloji": _MEREOLOJI_TERMS,
            "Bayes": _BAYES_TERMS,
            "OyunTeorisi": _OYUN_TERMS,
            "KategoriTeorisi": _KATEGORI_TERMS,
            "Topoloji + Holografik": _HOLOGRAFIK_TERMS,
        }

    # Expected minimum unique term matches per (text_id, lens).
    # Derived from v2.5.0 baseline.  0 = not expected to match.
    _EXPECTED_MINIMUMS = {
        ("birinciSoz", "Ontoloji"): 3,
        ("birinciSoz", "Mereoloji"): 5,
        ("birinciSoz", "Bayes"): 1,
        ("birinciSoz", "OyunTeorisi"): 0,
        ("birinciSoz", "KategoriTeorisi"): 1,
        ("birinciSoz", "Topoloji + Holografik"): 5,
        ("ikincisoz", "Ontoloji"): 3,
        ("ikincisoz", "Mereoloji"): 2,
        ("ikincisoz", "Bayes"): 2,
        ("ikincisoz", "OyunTeorisi"): 1,
        ("ikincisoz", "KategoriTeorisi"): 0,
        ("ikincisoz", "Topoloji + Holografik"): 1,
        ("ucuncusoz", "Ontoloji"): 1,
        ("ucuncusoz", "Mereoloji"): 3,
        ("ucuncusoz", "Bayes"): 1,
        ("ucuncusoz", "OyunTeorisi"): 4,
        ("ucuncusoz", "KategoriTeorisi"): 0,
        ("ucuncusoz", "Topoloji + Holografik"): 0,
    }

    @pytest.mark.parametrize(
        "text_id,lens_id",
        [
            (tid, lid)
            for tid in ("birinciSoz", "ikincisoz", "ucuncusoz")
            for lid in (
                "Ontoloji", "Mereoloji", "Bayes",
                "OyunTeorisi", "KategoriTeorisi", "Topoloji + Holografik",
            )
        ],
    )
    def test_term_match_minimum(self, text_id, lens_id):
        """Each (text, lens) pair must match at least N unique terms."""
        text, _ = _load_source_text(text_id)
        terms = self.term_sets[lens_id]
        hits = self.count(text, terms)
        expected_min = self._EXPECTED_MINIMUMS.get((text_id, lens_id), 0)
        assert hits >= expected_min, (
            f"{text_id}/{lens_id}: matched {hits} terms, expected >= {expected_min}"
        )


class TestExtractorRegexSensitivity:
    """Verify dusun.py extractor regexes fire on source texts."""

    def _matches(self, pattern: "re.Pattern", text: str) -> int:
        return len(pattern.findall(text))

    def test_bayes_signal_fires_on_birincisoz(self):
        from fw_server.dusun import _BAYES_SIGNAL
        text, _ = _load_source_text("birinciSoz")
        assert self._matches(_BAYES_SIGNAL, text) >= 1, (
            "birinciSoz should trigger _BAYES_SIGNAL (contingent/epistemic terms)"
        )

    def test_bayes_signal_fires_on_ikincisoz(self):
        from fw_server.dusun import _BAYES_SIGNAL
        text, _ = _load_source_text("ikincisoz")
        assert self._matches(_BAYES_SIGNAL, text) >= 2, (
            "ikincisoz should trigger _BAYES_SIGNAL (hermeneutic/epistemic terms)"
        )

    def test_part_terms_fire_on_birincisoz(self):
        from fw_server.dusun import _PART_TERMS
        text, _ = _load_source_text("birinciSoz")
        assert self._matches(_PART_TERMS, text) >= 2, (
            "birinciSoz has mereological structure (part, component, etc.)"
        )

    def test_whole_terms_fire_on_birincisoz(self):
        from fw_server.dusun import _WHOLE_TERMS
        text, _ = _load_source_text("birinciSoz")
        assert self._matches(_WHOLE_TERMS, text) >= 1, (
            "birinciSoz references whole/system/külliyat"
        )

    def test_holographic_signal_fires_on_birincisoz(self):
        from fw_server.dusun import _HOLOGRAPHIC_SIGNAL
        text, _ = _load_source_text("birinciSoz")
        assert self._matches(_HOLOGRAPHIC_SIGNAL, text) >= 2, (
            "birinciSoz has holographic seed theme"
        )

    def test_part_terms_fire_on_ucuncusoz(self):
        from fw_server.dusun import _PART_TERMS
        text, _ = _load_source_text("ucuncusoz")
        assert self._matches(_PART_TERMS, text) >= 3, (
            "ucuncusoz has faculty, phase, station, dimension terms"
        )

    def test_oyun_signal_fires_on_ucuncusoz(self):
        from fw_server.dusun import _OYUN_SIGNAL
        text, _ = _load_source_text("ucuncusoz")
        assert self._matches(_OYUN_SIGNAL, text) >= 2, (
            "ucuncusoz has game-theoretic language (payoff, station, strategy)"
        )

    def test_oyun_nefs_fires_on_ucuncusoz(self):
        from fw_server.dusun import _OYUN_NEFS
        text, _ = _load_source_text("ucuncusoz")
        assert self._matches(_OYUN_NEFS, text) >= 3, (
            "ucuncusoz explicitly names Emmare, Levvame, Mulhime, Mutmainne"
        )

    def test_ax_ref_pattern_fires_on_all_texts(self):
        from fw_server.dusun import _AX_REF_PATTERN
        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            hits = self._matches(_AX_REF_PATTERN, text)
            assert hits >= 3, (
                f"{text_id} should reference at least 3 axioms, got {hits}"
            )

    def test_kv_ref_pattern_fires_on_all_texts(self):
        from fw_server.dusun import _KV_REF_PATTERN
        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            hits = self._matches(_KV_REF_PATTERN, text)
            assert hits >= 1, (
                f"{text_id} should reference at least 1 kavaid, got {hits}"
            )


class TestPerLensTextScorers:
    """Verify _text_score_* functions produce expected magnitudes on source texts."""

    # Pinned regression anchors: (text_id, lens_id) -> (min_score, max_score)
    # Derived from v2.5.0 baseline with ±0.05 tolerance.
    _ANCHORS = {
        # birinciSoz
        ("birinciSoz", "Ontoloji"): (0.15, 0.65),
        ("birinciSoz", "Mereoloji"): (0.30, 0.50),
        ("birinciSoz", "FOL"): (0.50, 0.70),
        ("birinciSoz", "Bayes"): (0.03, 0.55),
        ("birinciSoz", "OyunTeorisi"): (0.00, 0.50),
        ("birinciSoz", "KategoriTeorisi"): (0.20, 0.45),
        ("birinciSoz", "Topoloji + Holografik"): (0.40, 0.55),
        # ikincisoz
        ("ikincisoz", "Ontoloji"): (0.12, 0.65),
        ("ikincisoz", "Mereoloji"): (0.10, 0.50),
        ("ikincisoz", "FOL"): (0.50, 0.70),
        ("ikincisoz", "Bayes"): (0.08, 0.55),
        ("ikincisoz", "OyunTeorisi"): (0.05, 0.50),
        ("ikincisoz", "KategoriTeorisi"): (0.08, 0.45),
        ("ikincisoz", "Topoloji + Holografik"): (0.00, 0.55),
        # ucuncusoz
        ("ucuncusoz", "Ontoloji"): (0.04, 0.65),
        ("ucuncusoz", "Mereoloji"): (0.15, 0.50),
        ("ucuncusoz", "FOL"): (0.50, 0.70),
        ("ucuncusoz", "Bayes"): (0.00, 0.55),
        ("ucuncusoz", "OyunTeorisi"): (0.35, 0.50),
        ("ucuncusoz", "KategoriTeorisi"): (0.30, 0.45),
        ("ucuncusoz", "Topoloji + Holografik"): (0.00, 0.55),
    }

    @pytest.mark.parametrize(
        "text_id,lens_id",
        [
            (tid, lid)
            for tid in ("birinciSoz", "ikincisoz", "ucuncusoz")
            for lid in _LENS_IDS
        ],
    )
    def test_text_scorer_in_range(self, text_id, lens_id):
        """Per-lens text scorer output falls within pinned regression range."""
        from fw_server.adapters import TEXT_SCORE_MAP

        text, _ = _load_source_text(text_id)
        scorer = TEXT_SCORE_MAP[lens_id]
        score = scorer(text)

        lo, hi = self._ANCHORS[(text_id, lens_id)]
        assert lo <= score <= hi, (
            f"{text_id}/{lens_id}: score={score:.4f}, expected [{lo}, {hi}]"
        )

    def test_text_scorers_respect_caps(self):
        """No text scorer ever exceeds its documented per-lens cap."""
        from fw_server.adapters import TEXT_SCORE_MAP

        caps = {
            "Ontoloji": 0.65,
            "Mereoloji": 0.50,
            "FOL": 0.70,
            "Bayes": 0.55,
            "OyunTeorisi": 0.50,
            "KategoriTeorisi": 0.45,
            "Topoloji + Holografik": 0.55,
        }
        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            for lid, cap in caps.items():
                score = TEXT_SCORE_MAP[lid](text)
                assert score <= cap + 1e-9, (
                    f"{text_id}/{lid}: score={score:.4f} exceeds cap={cap}"
                )

    def test_empty_text_scores_zero(self):
        """Empty string produces 0.0 for all per-lens text scorers."""
        from fw_server.adapters import TEXT_SCORE_MAP

        for lid, scorer in TEXT_SCORE_MAP.items():
            assert scorer("") == 0.0, f"{lid} scored non-zero on empty text"

    def test_word_boundary_no_false_positives(self):
        """Terms use word-boundary matching; 'department' must NOT match 'part'."""
        from fw_server.adapters import _count_text_matches, _MEREOLOJI_TERMS

        # "department" contains "part" as substring but not as a word
        hits = _count_text_matches("The department handles complaints.", _MEREOLOJI_TERMS)
        # Should match 0 mereoloji terms from this sentence
        assert hits == 0, (
            f"Word boundary violation: 'department' triggered {hits} mereoloji matches"
        )


# ═══════════════════════════════════════════════════════════════════════
# TIER 2: STRUCTURAL INVARIANT TESTS
# ═══════════════════════════════════════════════════════════════════════


class TestKV4ConvergenceBound:
    """KV₄: 0 < composite < 1 for all source texts. Flag ≥ 0.95 as error."""

    def test_composite_bounded_on_all_texts(self):
        from fw_server.dusun import dusun

        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            result = dusun(text)
            c = result["composite_score"]
            assert 0.0 < c < 1.0, (
                f"{text_id}: composite={c}, violates KV₄ (0 < C < 1)"
            )
            assert c < 0.95, (
                f"{text_id}: composite={c} >= 0.95, KV₄ convergence error"
            )

    def test_all_lens_scores_below_one(self):
        """No individual lens score reaches 1.0."""
        from fw_server.dusun import dusun

        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            result = dusun(text)
            for lid, score in result["lens_scores"].items():
                assert score < 1.0, (
                    f"{text_id}/{lid}: lens_score={score}, must be < 1.0"
                )


class TestT17CoverageBound:
    """T17: Coverage ≤ 6/7 — no text activates all 7 lenses above threshold."""

    # Threshold below which a lens is considered "inactive"
    _ACTIVATION_THRESHOLD = 0.05

    def test_no_text_activates_all_seven_lenses(self):
        from fw_server.adapters import compute_text_scores

        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            scores = compute_text_scores(text)
            active = sum(1 for s in scores.values() if s > self._ACTIVATION_THRESHOLD)
            assert active <= 6, (
                f"{text_id}: {active}/7 lenses active (> {self._ACTIVATION_THRESHOLD}), "
                f"violates T17 coverage bound. Scores: {scores}"
            )


class TestAX56MaxGrade:
    """AX56: Max grade = İlmelyakîn. Hakkalyakîn is permanently inaccessible."""

    def test_dusun_never_returns_hakkalyakin(self):
        from fw_server.dusun import dusun

        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            result = dusun(text)
            assert result["grade"] != "Hakkalyakîn", (
                f"{text_id}: grade=Hakkalyakîn, violates AX56"
            )

    def test_score_to_grade_never_returns_hakkalyakin(self):
        """Even score=1.0 must not produce Hakkalyakîn."""
        from fw_server.grade_map import score_to_grade

        for s in [0.0, 0.5, 0.86, 0.95, 0.99, 1.0]:
            g = score_to_grade(s)
            assert g.value != "Hakkalyakîn", (
                f"score_to_grade({s}) returned Hakkalyakîn"
            )

    def test_adaptive_grade_never_returns_hakkalyakin(self):
        """Even perfect inputs must not produce Hakkalyakîn."""
        from fw_server.grade_map import adaptive_score_to_grade, GradeInput

        perfect = GradeInput(
            composite_score=0.99,
            kavaid_pass_ratio=1.0,
            structured_ratio=1.0,
            lens_agreement=1.0,
            evidence_density=10.0,
            path="P4",
        )
        g = adaptive_score_to_grade(perfect)
        assert g.value != "Hakkalyakîn", (
            f"adaptive_score_to_grade with perfect input returned Hakkalyakîn"
        )


class TestAX52MultiplicativeGate:
    """AX52: Zero in one dimension = system-level collapse, not local issue."""

    def test_zero_dimension_lowers_composite(self):
        """If a key lens scores 0.0, composite should be noticeably lower."""
        from fw_server.dusun import dusun

        # ucuncusoz has Holografik=0.0 — composite should be lower than if
        # all lenses scored proportionally
        text, _ = _load_source_text("ucuncusoz")
        result = dusun(text)
        c = result["composite_score"]
        # With a 0.0 dimension, composite must be below 0.50
        # (bileshke uses weighted combination, zero drags it down)
        assert c < 0.50, (
            f"ucuncusoz: composite={c:.4f} despite Holografik=0.0. "
            f"AX52 multiplicative gate should drag composite down."
        )


class TestKavaidPassOnSourceTexts:
    """Kavaid checks must pass at a reasonable rate on canonical source texts."""

    def test_kavaid_pass_rate_above_threshold(self):
        from fw_server.dusun import dusun

        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            result = dusun(text)
            total = len(result["kavaid"])
            passed = result["kavaid_pass_count"]
            if total > 0:
                ratio = passed / total
                assert ratio >= 0.5, (
                    f"{text_id}: kavaid pass ratio={ratio:.2f} (passed={passed}, "
                    f"total={total}), expected >= 0.50"
                )


# ═══════════════════════════════════════════════════════════════════════
# TIER 3: GRADE REACHABILITY & CALIBRATION
# ═══════════════════════════════════════════════════════════════════════


class TestPathClassification:
    """All source texts in kaynak_metinler/ should classify as P4 (theological)."""

    def test_all_source_texts_classify_as_p4(self):
        from fw_server.dusun import dusun

        for text_id in _all_text_ids():
            text, expected = _load_source_text(text_id)
            result = dusun(text)
            expected_path = expected.get("expected_path", "P4")
            assert result["path"] == expected_path, (
                f"{text_id}: classified as {result['path']}, "
                f"expected {expected_path}. vocab={result['vocabulary_detected']}"
            )


class TestGradeReachability:
    """Each source text must reach at least its expected minimum grade."""

    def test_grade_meets_expected_minimum(self):
        from fw_server.dusun import dusun

        for text_id in _all_text_ids():
            text, expected = _load_source_text(text_id)
            result = dusun(text)
            actual_grade = result["grade"]
            expected_min = expected.get("expected_grade_min", "Tasavvur")
            actual_rank = _GRADE_ORDER.get(actual_grade, -1)
            expected_rank = _GRADE_ORDER.get(expected_min, 0)
            assert actual_rank >= expected_rank, (
                f"{text_id}: grade={actual_grade} (rank {actual_rank}) "
                f"< expected minimum {expected_min} (rank {expected_rank}). "
                f"composite={result['composite_score']:.4f}"
            )

    def test_at_least_one_text_reaches_tasdik(self):
        """At least one source text must achieve Tasdik or higher."""
        from fw_server.dusun import dusun

        grades = []
        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            result = dusun(text)
            grades.append(result["grade"])
        tasdik_or_higher = [g for g in grades if _GRADE_ORDER.get(g, 0) >= 1]
        assert len(tasdik_or_higher) >= 1, (
            f"No source text reached Tasdik. Grades: {grades}"
        )


class TestGradeStability:
    """dusun() must be deterministic — same text → same grade across runs."""

    def test_deterministic_across_three_runs(self):
        from fw_server.dusun import dusun

        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            results = [dusun(text) for _ in range(3)]
            grades = [r["grade"] for r in results]
            composites = [r["composite_score"] for r in results]
            assert len(set(grades)) == 1, (
                f"{text_id}: grades vary across runs: {grades}"
            )
            assert max(composites) - min(composites) < 1e-9, (
                f"{text_id}: composites vary: {composites}"
            )


class TestCompositeRegressionAnchors:
    """Pin composite scores to baseline ranges for regression detection."""

    # v2.5.0 baseline ± 0.08 tolerance
    _COMPOSITE_ANCHORS = {
        "birinciSoz": (0.20, 0.42),
        "ikincisoz": (0.08, 0.26),
        "ucuncusoz": (0.16, 0.35),
    }

    @pytest.mark.parametrize("text_id", list(_COMPOSITE_ANCHORS.keys()))
    def test_composite_in_regression_range(self, text_id):
        from fw_server.dusun import dusun

        text, _ = _load_source_text(text_id)
        result = dusun(text)
        lo, hi = self._COMPOSITE_ANCHORS[text_id]
        c = result["composite_score"]
        assert lo <= c <= hi, (
            f"{text_id}: composite={c:.4f}, regression range [{lo}, {hi}]"
        )


class TestVocabularyDetectionOnSourceTexts:
    """dusun() vocabulary detection must find expected vocabulary categories."""

    _EXPECTED_VOCAB = {
        "birinciSoz": {"axiom_ref", "theorem_ref", "holographic", "theological"},
        "ikincisoz": {"axiom_ref", "theorem_ref", "theological"},
        "ucuncusoz": {"axiom_ref", "theorem_ref", "theological"},
    }

    @pytest.mark.parametrize("text_id", list(_EXPECTED_VOCAB.keys()))
    def test_expected_vocab_detected(self, text_id):
        from fw_server.dusun import dusun

        text, _ = _load_source_text(text_id)
        result = dusun(text)
        detected = set(result["vocabulary_detected"])
        expected = self._EXPECTED_VOCAB[text_id]
        missing = expected - detected
        assert not missing, (
            f"{text_id}: missing expected vocabulary: {missing}. "
            f"detected={detected}"
        )


class TestFirePlanOnSourceTexts:
    """Fire plans must include default P4 lenses for theological texts."""

    _P4_DEFAULTS = {"Ontoloji", "FOL", "Topoloji + Holografik"}

    def test_p4_defaults_in_fire_plan(self):
        from fw_server.dusun import dusun

        for text_id in _all_text_ids():
            text, expected = _load_source_text(text_id)
            if expected.get("expected_path") != "P4":
                continue
            result = dusun(text)
            fire_plan = set(result["fire_plan"])
            missing = self._P4_DEFAULTS - fire_plan
            assert not missing, (
                f"{text_id}: P4 default lenses missing from fire_plan: {missing}. "
                f"fire_plan={result['fire_plan']}"
            )


class TestPatternDetectionOnSourceTexts:
    """Patterns in expected_patterns_min must appear in dusun() output."""

    def test_expected_patterns_detected(self):
        from fw_server.dusun import dusun

        for text_id in _all_text_ids():
            text, expected = _load_source_text(text_id)
            expected_patterns = set(expected.get("expected_patterns_min", []))
            if not expected_patterns:
                continue
            result = dusun(text)
            detected = set(result["patterns_applied"])
            missing = expected_patterns - detected
            assert not missing, (
                f"{text_id}: expected patterns missing: {missing}. "
                f"detected={sorted(detected)}"
            )


class TestAX57DisclosureOnSourceTexts:
    """AX57 compact disclosure must be present and well-formed on all texts."""

    def test_ax57_compact_present_and_nonempty(self):
        from fw_server.dusun import dusun

        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            result = dusun(text)
            ax57 = result.get("ax57_compact", "")
            assert ax57, f"{text_id}: ax57_compact is empty or missing"

    def test_ax57_contains_required_fields(self):
        from fw_server.dusun import dusun

        required = ["Framework:", "Patterns:", "Constraints:", "Grade:"]
        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            result = dusun(text)
            ax57 = result.get("ax57_compact", "")
            for field in required:
                assert field in ax57, (
                    f"{text_id}: ax57_compact missing '{field}': {ax57}"
                )


class TestGradeMapBoundaries:
    """Validate grade_map.py threshold boundaries independently."""

    def test_static_grade_boundaries(self):
        from fw_server.grade_map import score_to_grade
        from bileshke.types import EpistemicGrade

        assert score_to_grade(0.00).value == "Tasavvur"
        assert score_to_grade(0.30).value == "Tasavvur"
        assert score_to_grade(0.60).value == "Tasavvur"
        assert score_to_grade(0.61).value == "Tasdik"
        assert score_to_grade(0.85).value == "Tasdik"
        assert score_to_grade(0.86).value == "İlmelyakîn"
        assert score_to_grade(0.99).value == "İlmelyakîn"

    def test_adaptive_grade_p4_thresholds(self):
        """P4 adaptive thresholds: İlmelyakîn at 0.78, Tasdik at 0.50."""
        from fw_server.grade_map import adaptive_score_to_grade, GradeInput

        # Just below Tasdik threshold
        below_tasdik = GradeInput(
            composite_score=0.48,
            kavaid_pass_ratio=0.48,
            structured_ratio=0.48,
            lens_agreement=0.48,
            evidence_density=2.4,
            path="P4",
        )
        g = adaptive_score_to_grade(below_tasdik)
        assert g.value == "Tasavvur", f"Expected Tasavvur below P4 Tasdik threshold, got {g.value}"

        # Above Tasdik threshold
        above_tasdik = GradeInput(
            composite_score=0.60,
            kavaid_pass_ratio=0.55,
            structured_ratio=0.55,
            lens_agreement=0.55,
            evidence_density=3.0,
            path="P4",
        )
        g2 = adaptive_score_to_grade(above_tasdik)
        assert g2.value in ("Tasdik", "İlmelyakîn"), (
            f"Expected Tasdik+ above P4 Tasdik threshold, got {g2.value}"
        )

    def test_path_thresholds_exist_for_all_paths(self):
        """_PATH_THRESHOLDS must cover P0–P4."""
        from fw_server.grade_map import _PATH_THRESHOLDS

        for path in ("P0", "P1", "P2", "P3", "P4"):
            assert path in _PATH_THRESHOLDS, f"Missing threshold for {path}"
            ilm, tas = _PATH_THRESHOLDS[path]
            assert 0 < tas < ilm <= 1.0, (
                f"{path}: thresholds invalid: İlmelyakîn={ilm}, Tasdik={tas}"
            )

    def test_p2_stricter_than_p1(self):
        """P2 (epistemic evaluation) must have stricter thresholds than P1."""
        from fw_server.grade_map import _PATH_THRESHOLDS

        ilm_p1, tas_p1 = _PATH_THRESHOLDS["P1"]
        ilm_p2, tas_p2 = _PATH_THRESHOLDS["P2"]
        assert ilm_p2 >= ilm_p1, (
            f"P2 İlmelyakîn ({ilm_p2}) should be >= P1 ({ilm_p1})"
        )
        assert tas_p2 >= tas_p1, (
            f"P2 Tasdik ({tas_p2}) should be >= P1 ({tas_p1})"
        )


class TestAdapterDispatchWithSourceTexts:
    """End-to-end: adapters dispatch correctly with real source text content."""

    def test_all_adapters_return_lens_result(self):
        from fw_server.adapters import run_lens
        from bileshke.types import LensResult

        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            for lid in _LENS_IDS:
                result = run_lens(lid, {"text": text})
                assert isinstance(result, LensResult), (
                    f"{text_id}/{lid}: run_lens returned {type(result)}"
                )
                assert 0.0 <= result.score <= 1.0, (
                    f"{text_id}/{lid}: score={result.score} out of bounds"
                )

    def test_adapter_scores_match_text_scores(self):
        """When only text is provided (no structured model), adapter text-path
        scores should match compute_text_scores output."""
        from fw_server.adapters import run_lens, compute_text_scores

        for text_id in _all_text_ids():
            text, _ = _load_source_text(text_id)
            text_scores = compute_text_scores(text)
            for lid in _LENS_IDS:
                adapter_result = run_lens(lid, {"text": text})
                expected = text_scores[lid]
                # Allow small mismatch for lenses that have structured fallback paths
                # FOL always goes through yakinlasma, so text_score is a fallback
                if lid == "FOL":
                    continue
                assert abs(adapter_result.score - expected) < 0.05, (
                    f"{text_id}/{lid}: adapter={adapter_result.score:.4f} "
                    f"vs text_score={expected:.4f}"
                )
