"""
tests/test_phase5_cross_lens.py — Phase 5: Cross-Lens Correlation tests.

WATERFALL_PLAN §9 — 9 tests covering:
  5.01  correlation_matrix_identity      – identical scores → high |r|
  5.02  correlation_matrix_independent   – random independent scores → low |r|
  5.03  kv7_flag_high_correlation        – correlated pair → KV₇ flag
  5.04  tesanud_detection                – ≥3 active lenses → tesanüd
  5.05  infiradi_detection               – exactly 1 active → infirâdî
  5.06  score_ledger_accumulation        – ledger grows with record_lens_scores()
  5.07  cross_validation_below_10        – <10 data points → correlation skipped
  5.08  dusun_includes_cross_validation  – dusun() result has cross_validation key
  5.09  cross_validate_fire_and_forget   – errors produce safe degraded dict

Governing constraints:
  KV₇:  Independence — these tests validate that the cross-validation module
         correctly detects when lenses are NOT independent.
  KV₃:  Observer non-interference — the cross-validation module only reads
         scores, never perturbs lens execution.
  AX63: Tesanüd/İnfirâd — these tests validate the compositional asymmetry.
"""

from __future__ import annotations

import random
from typing import Any, Dict, List

import pytest

from fw_server.cross_validation import (
    ACTIVITY_THRESHOLD,
    KV7_THRESHOLD,
    LENS_IDS,
    MIN_DATA_POINTS,
    TESANUD_MIN_LENSES,
    _pearson_r,
    compute_lens_correlation,
    cross_validate,
    detect_infiradi,
    detect_tesanud,
)
from fw_server.session import SessionLedger, get_ledger, reset_ledger


# ── Helpers ──────────────────────────────────────────────────────────

def _make_scores(**overrides: float) -> Dict[str, float]:
    """Build a lens-score dict with zeros for all lenses, then apply overrides."""
    scores: Dict[str, float] = {lid: 0.0 for lid in LENS_IDS}
    for key, val in overrides.items():
        # Allow shorthand keys: "Ontoloji", "FOL", etc.
        if key in scores:
            scores[key] = val
    return scores


def _make_constant_history(n: int, value: float = 0.5) -> List[Dict[str, float]]:
    """Generate *n* identical score-history entries where every lens = *value*."""
    return [{lid: value for lid in LENS_IDS} for _ in range(n)]


def _make_random_history(n: int, *, seed: int = 42) -> List[Dict[str, float]]:
    """Generate *n* score-history entries with independent random scores."""
    rng = random.Random(seed)
    history: List[Dict[str, float]] = []
    for _ in range(n):
        history.append({lid: rng.random() for lid in LENS_IDS})
    return history


def _make_correlated_history(
    n: int,
    correlated_pair: tuple[str, str],
    *,
    seed: int = 42,
) -> List[Dict[str, float]]:
    """Generate *n* entries where *correlated_pair* always have identical scores.

    All other lenses get independent random scores.
    """
    rng = random.Random(seed)
    lid_a, lid_b = correlated_pair
    history: List[Dict[str, float]] = []
    for _ in range(n):
        entry: Dict[str, float] = {}
        shared = rng.random()
        for lid in LENS_IDS:
            if lid == lid_a or lid == lid_b:
                entry[lid] = shared
            else:
                entry[lid] = rng.random()
        history.append(entry)
    return history


# ── 5.01 — Identical scores → high correlation ──────────────────────

class TestCorrelationMatrixIdentity:
    """When all lenses always produce the same score, Pearson r should be
    undefined (zero variance → None) or very high."""

    def test_constant_scores_skip_or_none(self):
        """Constant identical scores → zero variance → r = None for all pairs."""
        history = _make_constant_history(15, value=0.5)
        result = compute_lens_correlation(history)

        assert not result["skipped"]
        assert result["data_points"] == 15

        # Every pair should have r = None (zero variance in both series)
        for key, r_val in result["correlation_matrix"].items():
            assert r_val is None, f"Expected None for {key}, got {r_val}"

    def test_proportional_scores_high_correlation(self):
        """Scores that differ per lens but are perfectly proportional → r = 1.0."""
        rng = random.Random(99)
        # Base values per lens (fixed proportions)
        base = {lid: 0.1 * (i + 1) for i, lid in enumerate(LENS_IDS)}
        history: List[Dict[str, float]] = []
        for _ in range(15):
            scale = rng.uniform(0.5, 2.0)
            history.append({lid: base[lid] * scale for lid in LENS_IDS})

        result = compute_lens_correlation(history)
        assert not result["skipped"]

        # All pairs should have r very close to 1.0
        for key, r_val in result["correlation_matrix"].items():
            assert r_val is not None, f"Unexpected None for {key}"
            assert r_val > 0.99, f"Expected r ≈ 1.0 for {key}, got {r_val}"


# ── 5.02 — Independent random scores → low correlation ──────────────

class TestCorrelationMatrixIndependent:
    """When lenses produce independent random scores, correlations should
    be low (|r| < KV7_THRESHOLD for all pairs, typically |r| < 0.3)."""

    def test_random_scores_low_correlation(self):
        history = _make_random_history(50, seed=42)
        result = compute_lens_correlation(history)

        assert not result["skipped"]
        assert result["data_points"] == 50

        # No KV₇ flags should fire on truly random data (with high probability)
        assert len(result["kv7_flags"]) == 0, (
            f"Unexpected KV₇ flags on random data: {result['kv7_flags']}"
        )

        # Spot-check: all |r| should be well below threshold
        for key, r_val in result["correlation_matrix"].items():
            if r_val is not None:
                assert abs(r_val) < KV7_THRESHOLD, (
                    f"|r| too high for random pair {key}: {r_val}"
                )


# ── 5.03 — Correlated pair → KV₇ flag ───────────────────────────────

class TestKV7FlagHighCorrelation:
    """When two lenses always produce identical scores, KV₇ should flag them."""

    def test_correlated_pair_flagged(self):
        lid_a, lid_b = "Ontoloji", "Mereoloji"
        history = _make_correlated_history(20, (lid_a, lid_b), seed=42)
        result = compute_lens_correlation(history)

        assert not result["skipped"]
        assert len(result["kv7_flags"]) >= 1

        # The flagged pair must include our correlated lenses
        flagged_pairs = {
            (f["lens_a"], f["lens_b"]) for f in result["kv7_flags"]
        }
        assert (lid_a, lid_b) in flagged_pairs or (lid_b, lid_a) in flagged_pairs

    def test_uncorrelated_pair_not_flagged(self):
        """Lenses NOT in the correlated pair should not be flagged together."""
        lid_a, lid_b = "Ontoloji", "Mereoloji"
        history = _make_correlated_history(20, (lid_a, lid_b), seed=42)
        result = compute_lens_correlation(history)

        # Check that unrelated pairs are not flagged
        for flag in result["kv7_flags"]:
            pair = {flag["lens_a"], flag["lens_b"]}
            # The flag should always involve at least one of the correlated pair
            assert pair & {lid_a, lid_b}, (
                f"Flag on unrelated pair: {flag}"
            )


# ── 5.04 — Tesanüd detection ────────────────────────────────────────

class TestTesanudDetection:
    """AX63: ≥3 independent lenses scoring above threshold → tesanüd."""

    def test_three_active_lenses_detected(self):
        scores = _make_scores(
            Ontoloji=0.5,
            Mereoloji=0.4,
            FOL=0.35,
            Bayes=0.1,
        )
        result = detect_tesanud(scores)

        assert result["detected"] is True
        assert result["active_count"] == 3
        assert set(result["active_lenses"]) == {"Ontoloji", "Mereoloji", "FOL"}

    def test_two_active_lenses_not_detected(self):
        scores = _make_scores(Ontoloji=0.5, Bayes=0.4)
        result = detect_tesanud(scores)

        assert result["detected"] is False
        assert result["active_count"] == 2

    def test_no_active_lenses(self):
        scores = _make_scores()  # all zeros
        result = detect_tesanud(scores)

        assert result["detected"] is False
        assert result["active_count"] == 0

    def test_all_seven_active(self):
        scores = {lid: 0.5 for lid in LENS_IDS}
        result = detect_tesanud(scores)

        assert result["detected"] is True
        assert result["active_count"] == 7

    def test_custom_threshold(self):
        scores = _make_scores(Ontoloji=0.2, Mereoloji=0.15, FOL=0.18)
        result = detect_tesanud(scores, threshold=0.1)

        assert result["detected"] is True
        assert result["active_count"] == 3


# ── 5.05 — İnfirâd detection ────────────────────────────────────────

class TestInfiradiDetection:
    """AX63: exactly 1 active lens → infirâdî (isolated finding)."""

    def test_single_active_lens_detected(self):
        scores = _make_scores(FOL=0.6)
        result = detect_infiradi(scores)

        assert result["detected"] is True
        assert result["isolated_lens"] == "FOL"
        assert result["active_count"] == 1

    def test_zero_active_lenses_not_infiradi(self):
        """No active lenses → not infirâdî (nothing to isolate)."""
        scores = _make_scores()
        result = detect_infiradi(scores)

        assert result["detected"] is False
        assert result["isolated_lens"] is None
        assert result["active_count"] == 0

    def test_multiple_active_lenses_not_infiradi(self):
        scores = _make_scores(Ontoloji=0.5, Bayes=0.4)
        result = detect_infiradi(scores)

        assert result["detected"] is False
        assert result["isolated_lens"] is None
        assert result["active_count"] == 2


# ── 5.06 — Score ledger accumulation ────────────────────────────────

class TestScoreLedgerAccumulation:
    """SessionLedger.record_lens_scores() accumulates across calls."""

    def setup_method(self):
        reset_ledger()

    def teardown_method(self):
        reset_ledger()

    def test_ledger_grows(self):
        ledger = get_ledger()
        assert len(ledger.get_score_history()) == 0

        for i in range(5):
            ledger.record_lens_scores({lid: 0.1 * i for lid in LENS_IDS})

        assert len(ledger.get_score_history()) == 5

    def test_ledger_returns_copies(self):
        """get_score_history() returns a copy, not a reference."""
        ledger = get_ledger()
        ledger.record_lens_scores({"Ontoloji": 0.5})

        history = ledger.get_score_history()
        history.clear()  # mutate the returned list

        # Original ledger should be unaffected
        assert len(ledger.get_score_history()) == 1

    def test_ledger_stores_copies_of_input(self):
        """record_lens_scores() stores a copy, not a reference."""
        ledger = get_ledger()
        scores = {"Ontoloji": 0.5}
        ledger.record_lens_scores(scores)

        scores["Ontoloji"] = 999.0  # mutate original

        # Stored entry should be unaffected
        assert ledger.get_score_history()[0]["Ontoloji"] == 0.5

    def test_reset_clears_score_ledger(self):
        ledger = get_ledger()
        ledger.record_lens_scores({"Ontoloji": 0.5})
        assert len(ledger.get_score_history()) == 1

        ledger.reset()
        assert len(ledger.get_score_history()) == 0

    def test_cumulative_context_includes_ledger_size(self):
        ledger = get_ledger()
        for _ in range(3):
            ledger.record_lens_scores({lid: 0.1 for lid in LENS_IDS})

        ctx = ledger.get_cumulative_context()
        assert ctx["score_ledger_size"] == 3

    def test_fire_and_forget_on_bad_input(self):
        """Passing non-dict doesn't raise."""
        ledger = get_ledger()
        ledger.record_lens_scores(None)  # type: ignore
        ledger.record_lens_scores(42)    # type: ignore
        assert len(ledger.get_score_history()) == 0


# ── 5.07 — Cross-validation below MIN_DATA_POINTS ───────────────────

class TestCrossValidationBelowMinimum:
    """With fewer than MIN_DATA_POINTS entries, correlation is skipped."""

    def test_skip_with_few_entries(self):
        scores = _make_scores(Ontoloji=0.5)
        history = _make_constant_history(MIN_DATA_POINTS - 1)

        result = cross_validate(scores, history)

        assert result["correlation"]["skipped"] is True
        assert "insufficient" in result["correlation"].get("reason", "")
        # Tesanüd/infirâd still work (they don't need history)
        assert "detected" in result["tesanud"]
        assert "detected" in result["infiradi"]

    def test_skip_with_empty_history(self):
        scores = _make_scores(Ontoloji=0.5)
        result = cross_validate(scores, [])

        assert result["correlation"]["skipped"] is True
        assert result["correlation"]["data_points"] == 0


# ── 5.08 — dusun() includes cross_validation key ────────────────────

class TestDusunIncludesCrossValidation:
    """Full dusun() call should include 'cross_validation' in result."""

    def setup_method(self):
        reset_ledger()

    def teardown_method(self):
        reset_ledger()

    def test_cross_validation_key_present(self):
        """dusun() result dict must have 'cross_validation' key."""
        from fw_server.dusun import dusun

        result = dusun("The system exhibits ontological dependency and structural gaps.")
        assert "cross_validation" in result
        cv = result["cross_validation"]

        # Must have the three sub-keys
        assert "correlation" in cv
        assert "tesanud" in cv
        assert "infiradi" in cv
        assert "kv7_status" in cv

    def test_cross_validation_correlation_skipped_on_first_call(self):
        """First call → only 1 data point in ledger → correlation skipped."""
        from fw_server.dusun import dusun

        result = dusun("Test structural analysis of formal systems.")
        cv = result["cross_validation"]

        assert cv["correlation"]["skipped"] is True
        assert cv["correlation"]["data_points"] < MIN_DATA_POINTS


# ── 5.09 — Fire-and-forget resilience ───────────────────────────────

class TestCrossValidateFireAndForget:
    """cross_validate() must not raise even on bad input."""

    def test_none_scores(self):
        result = cross_validate(None, [])  # type: ignore
        # Should return degraded dict, not crash
        assert isinstance(result, dict)
        assert "kv7_status" in result

    def test_malformed_history(self):
        result = cross_validate({"Ontoloji": 0.5}, [None, "bad", 42])  # type: ignore
        assert isinstance(result, dict)


# ── Pearson r unit tests ─────────────────────────────────────────────

class TestPearsonR:
    """Unit tests for the stdlib-only Pearson implementation."""

    def test_perfect_positive(self):
        r = _pearson_r([1.0, 2.0, 3.0, 4.0], [2.0, 4.0, 6.0, 8.0])
        assert r is not None
        assert abs(r - 1.0) < 1e-9

    def test_perfect_negative(self):
        r = _pearson_r([1.0, 2.0, 3.0, 4.0], [8.0, 6.0, 4.0, 2.0])
        assert r is not None
        assert abs(r + 1.0) < 1e-9

    def test_zero_variance_returns_none(self):
        r = _pearson_r([5.0, 5.0, 5.0, 5.0], [1.0, 2.0, 3.0, 4.0])
        assert r is None

    def test_too_few_points_returns_none(self):
        r = _pearson_r([1.0, 2.0], [3.0, 4.0])
        assert r is None

    def test_mismatched_lengths_returns_none(self):
        r = _pearson_r([1.0, 2.0, 3.0], [1.0, 2.0])
        assert r is None

    def test_bounded_in_minus_one_to_one(self):
        """Result should be clamped to [-1, 1]."""
        rng = random.Random(123)
        xs = [rng.gauss(0, 1) for _ in range(50)]
        ys = [rng.gauss(0, 1) for _ in range(50)]
        r = _pearson_r(xs, ys)
        assert r is not None
        assert -1.0 <= r <= 1.0
