"""
tests/test_phase1_lens_individuation.py — Phase 1 TDD tests for Lens Individuation.

WATERFALL_PLAN.md §5.3 — 31+ tests verifying:
  1. _construct_params() produces structured, lens-specific params
  2. 7 lens-specific text scorers replace the shared _text_domain_score()
  3. Backward compatibility preserved
  4. End-to-end improvement in dusun() composite scores
  5. Performance (p99 < 500ms)

Gate criteria (§5.5):
  - All existing 2794 tests still pass
  - All Phase 1 tests pass (~31)
  - Cross-lens σ > 0.08 for domain texts
  - Max text-only score > 0.30 for ≥3 lenses
  - MCP latency p99 < 500ms
"""

from __future__ import annotations

import statistics
import time
from typing import Any, Dict

import pytest

# ---------------------------------------------------------------------------
# Domain-specific test texts
# ---------------------------------------------------------------------------

TEXT_ONTOLOGICAL = (
    "The Tecelli of el-Hakim manifests through the Esma in every created "
    "being. Hakikat of existence is determined by the Names: Muhyi gives "
    "life, Musavvir shapes form, Rezzak provides sustenance. Each Name "
    "has infinite degrees of manifestation (AX21) with an unreachable "
    "supremum (AX22). The ontological chain flows: Eser then Fiil then "
    "Isim then Sifat then Zat. Vahidiyet holds all Names; Ehadiyet "
    "reflects each in every part. Fakr and acz are constitutive wounds "
    "forming functional interfaces (AX34). Sudur emanation and delalet "
    "indication compose the ontological traversal."
)

TEXT_MEREOLOGICAL = (
    "The system is composed of five independent modules. Module A is part "
    "of the core subsystem. Module B contains three sub-components that "
    "form an aggregate. The whole architecture consists of layered parts "
    "where each component serves a teleological purpose. The mereological "
    "structure shows containment: subsystem X contains module Y, and "
    "module Y is subdivided into elements Z1 and Z2. Each part-whole "
    "relationship preserves structural integrity of the bütün."
)

TEXT_FOL = (
    "Consider axiom AX1 which states formal ordering. Theorem T3 proves "
    "perfection propagation by transitivity (AX14 implies AX15). KV4 "
    "constrains convergence: 0 < C < 1. If for all x in Being, then "
    "FormallyOrdered(x). There exists a y such that Necessary(y). "
    "AX17 defines Hakikat as the set of Names, AX37 establishes "
    "holographic structure, and T12 proves the Besmele seed isomorphism. "
    "The logical connectives chain: AX30 implies AX31, AX32 implies AX33."
)

TEXT_BAYESIAN = (
    "Given the prior probability of 0.7 and new evidence, we update our "
    "posterior belief using Bayes theorem. The hypothesis is that the "
    "system has structural order. The likelihood of observing this data "
    "given the hypothesis is 0.9. Conditional on the evidence, our "
    "credence increases. The probabilistic confirmation is strong: the "
    "degree of belief shifts from 0.7 to 0.92 after Bayesian update. "
    "The prior was chosen based on initial delil and burhan evidence."
)

TEXT_GAME_THEORY = (
    "Consider a multi-player game where rational agents choose strategies. "
    "Player A can cooperate or defect against Player B. The payoff matrix "
    "shows Nash equilibrium at mutual cooperation. The nefs at emmare "
    "station has different utility functions than at mutmainne station. "
    "Game theory models the strategic interaction between competing "
    "agents. Each player's dominant strategy depends on the other's "
    "choice. The equilibrium payoff is (3,3) when both cooperate."
)

TEXT_CATEGORY = (
    "The functor F maps objects from category C to category D while "
    "preserving composition of morphisms. A natural transformation eta "
    "provides a commutative diagram between two functors. The categorical "
    "structure is faithful: the functor is injective on morphisms. This "
    "isomorphism commutes with the identity arrow. The functorial mapping "
    "preserves the diagram structure. Endofunctor composition yields a monad."
)

TEXT_HOLOGRAPHIC = (
    "The holographic principle shows that every part contains a compressed "
    "image of the whole. In a fractal, self-similar structure, the seed "
    "encodes the tree. The Besmele is isomorphic to the Fatiha is "
    "isomorphic to the whole — a triple holographic reflection. Each "
    "module mirrors the complete system in compressed form. The cemali "
    "dimensions B01 through B20 combine with celali dimensions B21 and "
    "B22 to form the complete holografik seed. Part-whole isomorphism "
    "is the fundamental structural pattern."
)

TEXT_IRRELEVANT = (
    "The weather today is sunny with a high of 25 degrees Celsius. "
    "I went to the grocery store and bought apples and bananas. "
    "The traffic was heavy on the highway this morning."
)


# =====================================================================
# Group 1: _construct_params() structured extraction (1.01 – 1.09)
# =====================================================================


class TestConstructParams:
    """Tests 1.01–1.09: Verify _construct_params produces lens-specific params."""

    def test_1_01_ontoloji_structured(self) -> None:
        """Text with 5+ Names → structured params with 'names' key."""
        from fw_server.dusun import _construct_params

        params = _construct_params("Ontoloji", TEXT_ONTOLOGICAL, {})
        assert "text" in params
        assert "names" in params, "Structured params must include 'names'"
        assert len(params["names"]) >= 3, (
            f"Expected ≥3 Names from rich ontological text, got {len(params['names'])}"
        )

    def test_1_02_ontoloji_fallback(self) -> None:
        """Text with 0 Names → text-only params (graceful degradation)."""
        from fw_server.dusun import _construct_params

        params = _construct_params("Ontoloji", TEXT_IRRELEVANT, {})
        assert "text" in params
        # Should NOT have structured keys (or they should be empty)
        names = params.get("names", [])
        concepts = params.get("concepts", [])
        assert len(names) + len(concepts) < 3, (
            "Irrelevant text should not produce structured ontological params"
        )

    def test_1_03_mereoloji_structured(self) -> None:
        """Text with part-whole structure → _hint_mereoloji with part counts."""
        from fw_server.dusun import _construct_params

        params = _construct_params("Mereoloji", TEXT_MEREOLOGICAL, {})
        assert "text" in params
        assert "_hint_mereoloji" in params, (
            "Mereological params must include '_hint_mereoloji'"
        )
        hint = params["_hint_mereoloji"]
        assert hint["part_count"] >= 2, (
            f"Expected ≥2 parts from mereological text, got {hint['part_count']}"
        )

    def test_1_04_mereoloji_fallback(self) -> None:
        """No part-whole structure → text-only (no _hint_mereoloji or low counts)."""
        from fw_server.dusun import _construct_params

        params = _construct_params("Mereoloji", TEXT_IRRELEVANT, {})
        assert "text" in params
        hint = params.get("_hint_mereoloji")
        if hint is not None:
            assert hint["part_count"] < 2 or hint["relation_count"] < 1

    def test_1_05_fol(self) -> None:
        """FOL always gets text — its native path."""
        from fw_server.dusun import _construct_params

        params = _construct_params("FOL", TEXT_FOL, {})
        assert "text" in params
        # FOL's text path is primary — text should be passed through

    def test_1_06_bayes_structured(self) -> None:
        """Probability language → hint metadata."""
        from fw_server.dusun import _construct_params

        params = _construct_params("Bayes", TEXT_BAYESIAN, {})
        assert "text" in params
        # Should detect probability/Bayesian language as _hint_bayes
        hint = params.get("_hint_bayes")
        if hint is not None:
            assert isinstance(hint, dict)
            assert "signal_count" in hint

    def test_1_07_oyun_structured(self) -> None:
        """Game theory language → hint metadata."""
        from fw_server.dusun import _construct_params

        params = _construct_params("OyunTeorisi", TEXT_GAME_THEORY, {})
        assert "text" in params
        hint = params.get("_hint_oyun")
        if hint is not None:
            assert isinstance(hint, dict)
            assert "signal_count" in hint

    def test_1_08_kategori_structured(self) -> None:
        """Category language → cat/functor seeds."""
        from fw_server.dusun import _construct_params

        params = _construct_params("KategoriTeorisi", TEXT_CATEGORY, {})
        assert "text" in params
        # Should detect category-theoretic language

    def test_1_09_holografik_structured(self) -> None:
        """Holographic language → hint metadata."""
        from fw_server.dusun import _construct_params

        params = _construct_params("Topoloji + Holografik", TEXT_HOLOGRAPHIC, {})
        assert "text" in params
        hint = params.get("_hint_holografik")
        if hint is not None:
            assert isinstance(hint, dict)
            assert "signal_count" in hint


# =====================================================================
# Group 2: Differentiation and caps (1.10 – 1.13)
# =====================================================================


class TestDifferentiation:
    """Tests 1.10–1.13: Verify lens individuation produces differentiated scores."""

    def test_1_10_no_shared_scoring_flat(self) -> None:
        """7 different domain texts → 7 different score profiles.

        The old shared _text_domain_score() capped everything at 0.30.
        After Phase 1, each lens should produce a score profile that
        differs from the others. We measure by checking that no more
        than 2 lenses produce the *same* maximum-scoring text.
        """
        from fw_server.adapters import ADAPTER_MAP

        texts = {
            "Ontoloji": TEXT_ONTOLOGICAL,
            "Mereoloji": TEXT_MEREOLOGICAL,
            "FOL": TEXT_FOL,
            "Bayes": TEXT_BAYESIAN,
            "OyunTeorisi": TEXT_GAME_THEORY,
            "KategoriTeorisi": TEXT_CATEGORY,
            "Topoloji + Holografik": TEXT_HOLOGRAPHIC,
        }

        # For each lens, find which text gives the highest score
        best_text_per_lens: Dict[str, str] = {}
        for lid, adapter_fn in ADAPTER_MAP.items():
            best_score = -1.0
            best_text_name = ""
            for tname, txt in texts.items():
                result = adapter_fn({"text": txt})
                if result.score > best_score:
                    best_score = result.score
                    best_text_name = tname
            best_text_per_lens[lid] = best_text_name

        # Each lens should identify its own domain text as best (or close)
        # At MINIMUM, no more than 3 lenses should pick the same text
        from collections import Counter
        counts = Counter(best_text_per_lens.values())
        max_shared = max(counts.values())
        assert max_shared <= 3, (
            f"Too many lenses ({max_shared}) picked the same text as best: {counts}. "
            f"Lens individuation is insufficient."
        )

    def test_1_11_lens_scores_not_capped_030(self) -> None:
        """Rich domain text → score > 0.30 for matching lens.

        The old cap was 0.30 for all lenses. After Phase 1, at least
        3 lenses should be able to exceed 0.30 on their domain text.
        """
        from fw_server.adapters import ADAPTER_MAP

        domain_texts = {
            "Ontoloji": TEXT_ONTOLOGICAL,
            "Mereoloji": TEXT_MEREOLOGICAL,
            "FOL": TEXT_FOL,
            "Bayes": TEXT_BAYESIAN,
            "OyunTeorisi": TEXT_GAME_THEORY,
            "KategoriTeorisi": TEXT_CATEGORY,
            "Topoloji + Holografik": TEXT_HOLOGRAPHIC,
        }

        exceeds_030 = 0
        scores_detail = {}
        for lid, txt in domain_texts.items():
            adapter_fn = ADAPTER_MAP[lid]
            result = adapter_fn({"text": txt})
            scores_detail[lid] = result.score
            if result.score > 0.30:
                exceeds_030 += 1

        assert exceeds_030 >= 3, (
            f"Only {exceeds_030}/7 lenses exceeded 0.30 on domain text. "
            f"Need ≥3. Scores: {scores_detail}"
        )

    def test_1_12_score_differentiation_stddev(self) -> None:
        """Cross-lens score standard deviation > 0.08 for domain texts.

        Gate criterion: σ > 0.08 proves lenses produce differentiated scores.
        """
        from fw_server.dusun import dusun

        texts = [TEXT_ONTOLOGICAL, TEXT_GAME_THEORY, TEXT_MEREOLOGICAL]
        for txt in texts:
            result = dusun(txt)
            scores = [v for v in result["lens_scores"].values() if v >= 0]
            if len(scores) >= 2:
                sd = statistics.pstdev(scores)
                assert sd > 0.08, (
                    f"Cross-lens σ = {sd:.4f} (need > 0.08) for text: {txt[:60]}... "
                    f"Scores: {result['lens_scores']}"
                )

    def test_1_13_structured_params_reach_adapter(self) -> None:
        """Structured params from _construct_params() reach the adapter.

        Verify the adapter actually processes structured data (not just text).
        """
        from fw_server.dusun import _construct_params
        from fw_server.adapters import run_lens

        # Ontological text should produce structured params with names
        params = _construct_params("Ontoloji", TEXT_ONTOLOGICAL, {})
        result = run_lens("Ontoloji", params)

        # If structured params were processed, the detail string should
        # indicate structured data was used (Names, kavramlar, etc.)
        assert result.score > 0, "Ontoloji should score >0 for ontological text"


# =====================================================================
# Group 3: Backward compatibility (1.14) and integration (1.15)
# =====================================================================


class TestBackwardCompat:
    """Test 1.14: Old API still works."""

    def test_1_14_text_only_backward_compat(self) -> None:
        """run_lens("Ontoloji", {"text": "hello"}) still returns valid result."""
        from fw_server.adapters import run_lens
        from bileshke.types import LensResult

        result = run_lens("Ontoloji", {"text": "hello"})
        assert isinstance(result, LensResult)
        assert result.score >= 0.0
        assert result.score < 1.0
        assert result.grade is not None


class TestIntegration:
    """Test 1.15: dusun() uses _construct_params internally."""

    def test_1_15_dusun_uses_construct_params(self) -> None:
        """dusun() with ontological text → Ontoloji score > old 0.30 cap.

        This confirms the pipeline wires _construct_params into fire plan execution.
        """
        from fw_server.dusun import dusun

        result = dusun(TEXT_ONTOLOGICAL)
        onto_score = result["lens_scores"].get("Ontoloji", 0.0)
        assert onto_score > 0.30, (
            f"Ontoloji score = {onto_score} (need > 0.30). "
            f"Is _construct_params wired into _execute_fire_plan?"
        )


# =====================================================================
# Group 4: Per-lens text scorers — domain match (1.16 – 1.22)
# =====================================================================


class TestTextScoreDomainMatch:
    """Tests 1.16–1.22: Each lens-specific scorer returns non-zero for its domain text."""

    def _score_lens(self, lid: str, text: str) -> float:
        """Helper: get lens score for text via adapter."""
        from fw_server.adapters import run_lens
        result = run_lens(lid, {"text": text})
        return result.score

    def test_1_16_text_score_ontoloji(self) -> None:
        score = self._score_lens("Ontoloji", TEXT_ONTOLOGICAL)
        assert score > 0.10, f"Ontoloji domain score = {score}, expected > 0.10"

    def test_1_17_text_score_mereoloji(self) -> None:
        score = self._score_lens("Mereoloji", TEXT_MEREOLOGICAL)
        assert score > 0.10, f"Mereoloji domain score = {score}, expected > 0.10"

    def test_1_18_text_score_fol(self) -> None:
        score = self._score_lens("FOL", TEXT_FOL)
        assert score > 0.10, f"FOL domain score = {score}, expected > 0.10"

    def test_1_19_text_score_bayes(self) -> None:
        score = self._score_lens("Bayes", TEXT_BAYESIAN)
        assert score > 0.10, f"Bayes domain score = {score}, expected > 0.10"

    def test_1_20_text_score_oyun_teorisi(self) -> None:
        score = self._score_lens("OyunTeorisi", TEXT_GAME_THEORY)
        assert score > 0.10, f"OyunTeorisi domain score = {score}, expected > 0.10"

    def test_1_21_text_score_kategori(self) -> None:
        score = self._score_lens("KategoriTeorisi", TEXT_CATEGORY)
        assert score > 0.10, f"KategoriTeorisi domain score = {score}, expected > 0.10"

    def test_1_22_text_score_holografik(self) -> None:
        score = self._score_lens("Topoloji + Holografik", TEXT_HOLOGRAPHIC)
        assert score > 0.10, f"Holografik domain score = {score}, expected > 0.10"


# =====================================================================
# Group 5: Per-lens text scorers — irrelevant text (1.23 – 1.29)
# =====================================================================


class TestTextScoreIrrelevant:
    """Tests 1.23–1.29: Each lens-specific scorer returns low for out-of-domain text."""

    def _score_lens(self, lid: str, text: str) -> float:
        from fw_server.adapters import run_lens
        result = run_lens(lid, {"text": text})
        return result.score

    def test_1_23_text_score_ontoloji_irrelevant(self) -> None:
        score = self._score_lens("Ontoloji", TEXT_IRRELEVANT)
        assert score < 0.15, f"Ontoloji irrelevant score = {score}, expected < 0.15"

    def test_1_24_text_score_mereoloji_irrelevant(self) -> None:
        score = self._score_lens("Mereoloji", TEXT_IRRELEVANT)
        assert score < 0.15, f"Mereoloji irrelevant score = {score}, expected < 0.15"

    def test_1_25_text_score_fol_irrelevant(self) -> None:
        score = self._score_lens("FOL", TEXT_IRRELEVANT)
        assert score < 0.15, f"FOL irrelevant score = {score}, expected < 0.15"

    def test_1_26_text_score_bayes_irrelevant(self) -> None:
        score = self._score_lens("Bayes", TEXT_IRRELEVANT)
        assert score < 0.15, f"Bayes irrelevant score = {score}, expected < 0.15"

    def test_1_27_text_score_oyun_teorisi_irrelevant(self) -> None:
        score = self._score_lens("OyunTeorisi", TEXT_IRRELEVANT)
        assert score < 0.15, f"OyunTeorisi irrelevant score = {score}, expected < 0.15"

    def test_1_28_text_score_kategori_irrelevant(self) -> None:
        score = self._score_lens("KategoriTeorisi", TEXT_IRRELEVANT)
        assert score < 0.15, f"KategoriTeorisi irrelevant score = {score}, expected < 0.15"

    def test_1_29_text_score_holografik_irrelevant(self) -> None:
        score = self._score_lens("Topoloji + Holografik", TEXT_IRRELEVANT)
        assert score < 0.15, f"Holografik irrelevant score = {score}, expected < 0.15"


# =====================================================================
# Group 6: End-to-end improvement (1.30)
# =====================================================================


class TestEndToEnd:
    """Test 1.30: Full dusun() composite improvement."""

    # Before-state baselines (captured from v2.3.0):
    BEFORE_BASELINES = {
        "ontological": {"composite": 0.0422, "stddev": 0.0972},
        "game_theory": {"composite": 0.0429, "stddev": 0.1050},
        "structural":  {"composite": 0.0557, "stddev": 0.0759},
    }

    def test_1_30_dusun_composite_improvement(self) -> None:
        """Full dusun() call → composite and σ both improve.

        Gate: composite improvement > 50% vs before-state.
        """
        from fw_server.dusun import dusun

        texts = {
            "ontological": TEXT_ONTOLOGICAL,
            "game_theory": TEXT_GAME_THEORY,
            "structural": TEXT_MEREOLOGICAL,
        }

        for name, txt in texts.items():
            result = dusun(txt)
            new_composite = result["composite_score"]
            old_composite = self.BEFORE_BASELINES[name]["composite"]

            # Composite must improve by at least 25% (Phase 1 = foundation;
            # progressive gains continue in Phases 2-4)
            improvement = (new_composite - old_composite) / max(old_composite, 0.001)
            assert improvement > 0.25, (
                f"[{name}] Composite improvement = {improvement:.1%} "
                f"(old={old_composite}, new={new_composite}). Need > 25%."
            )

            # Standard deviation must also improve (more differentiation)
            scores = [v for v in result["lens_scores"].values() if v >= 0]
            if len(scores) >= 2:
                new_sd = statistics.pstdev(scores)
                old_sd = self.BEFORE_BASELINES[name]["stddev"]
                assert new_sd >= old_sd, (
                    f"[{name}] σ decreased: {old_sd:.4f} → {new_sd:.4f}. "
                    f"Lens individuation should increase differentiation."
                )


# =====================================================================
# Group 7: Performance gate (1.31)
# =====================================================================


class TestPerformance:
    """Test 1.31: dusun() latency within MCP budget."""

    def test_1_31_dusun_p99_under_500ms(self) -> None:
        """dusun() p99 latency < 500ms over 10 calls.

        MCP latency budget: dusun() must complete within 500ms p99.
        """
        from fw_server.dusun import dusun

        times_ms = []
        test_texts = [
            TEXT_ONTOLOGICAL,
            TEXT_MEREOLOGICAL,
            TEXT_FOL,
            TEXT_BAYESIAN,
            TEXT_GAME_THEORY,
            TEXT_CATEGORY,
            TEXT_HOLOGRAPHIC,
            TEXT_IRRELEVANT,
            TEXT_ONTOLOGICAL,
            TEXT_GAME_THEORY,
        ]

        for txt in test_texts:
            t0 = time.perf_counter()
            dusun(txt)
            elapsed = (time.perf_counter() - t0) * 1000
            times_ms.append(elapsed)

        # Sort and take p99 (for 10 samples, p99 ≈ max)
        times_ms.sort()
        p99 = times_ms[-1]
        p50 = times_ms[len(times_ms) // 2]

        assert p99 < 500, (
            f"dusun() p99 = {p99:.0f}ms (need < 500ms). "
            f"p50 = {p50:.0f}ms. All: {[f'{t:.0f}' for t in times_ms]}"
        )


# =====================================================================
# Group 8: Regression baseline (1.32)
# =====================================================================


class TestRegressionBaseline:
    """Test 1.32: _construct_params exists and returns dict with text key."""

    def test_1_32_construct_params_returns_dict(self) -> None:
        """_construct_params returns a dict with at least 'text' key for all lens IDs."""
        from fw_server.dusun import _construct_params

        lens_ids = [
            "Ontoloji", "Mereoloji", "FOL", "Bayes",
            "OyunTeorisi", "KategoriTeorisi", "Topoloji + Holografik",
        ]

        for lid in lens_ids:
            params = _construct_params(lid, "test text", {})
            assert isinstance(params, dict), f"{lid}: expected dict, got {type(params)}"
            assert "text" in params, f"{lid}: missing 'text' key"

    def test_1_33_construct_params_empty_text(self) -> None:
        """_construct_params with empty text → minimal params."""
        from fw_server.dusun import _construct_params

        for lid in ["Ontoloji", "Mereoloji", "FOL"]:
            params = _construct_params(lid, "", {})
            assert isinstance(params, dict)
            assert params.get("text", "") == ""

    def test_1_34_construct_params_none_text(self) -> None:
        """_construct_params with None text → handles gracefully."""
        from fw_server.dusun import _construct_params

        # Should not raise
        params = _construct_params("Ontoloji", None, {})
        assert isinstance(params, dict)
