"""
fw_server/cross_validation.py — Cross-Lens Correlation & Validation.

Phase 5 of WATERFALL_PLAN.md — addresses:
  BS-10: No cross-validation between lenses
  BS-11: Lens independence (KV₇) not verified empirically
  BS-17: Tesanüd/İnfirâd invisible to the LLM

Implements:
  KV₇  (İhlâs):            Independence verification between lens scores
  AX63 (Tesanüd):          Super-additive convergence detection
  AX63 (İnfirâd):          Isolated finding detection
  T8   (Cascade Neighbor.): Cross-lens prediction awareness

Uses stdlib ``statistics`` only (no numpy/scikit-learn).  Phase 4 gate
evaluated at ρ = 0.6944 → SKIP, so heavyweight NLP deps are absent.

Design principles:
  KV₃ compliance:  All functions are pure — no mutation of external state.
  KV₇ compliance:  Functions only read lens scores; lenses never read this module.
  Fire-and-forget: ``cross_validate()`` swallows internal errors gracefully.
"""

from __future__ import annotations

import logging
from statistics import mean, stdev
from typing import Any, Dict, List, Optional

logger = logging.getLogger("fw_server.cross_validation")

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

# The 7 canonical lens IDs — must match adapters.py TEXT_SCORE_MAP keys.
LENS_IDS: List[str] = [
    "Ontoloji",
    "Mereoloji",
    "FOL",
    "Bayes",
    "OyunTeorisi",
    "KategoriTeorisi",
    "Topoloji + Holografik",
]

#: Minimum data points for correlation to be meaningful (statistics heuristic).
MIN_DATA_POINTS: int = 10

#: Pearson |r| threshold above which a lens pair is flagged for KV₇ concern.
KV7_THRESHOLD: float = 0.80

#: Score threshold for a lens to be "active" in tesanüd/infirâd detection.
ACTIVITY_THRESHOLD: float = 0.30

#: Minimum independent active lenses for tesanüd (AX63).
TESANUD_MIN_LENSES: int = 3


# ── Pearson correlation (stdlib-only) ────────────────────────────────

def _pearson_r(xs: List[float], ys: List[float]) -> Optional[float]:
    """Compute Pearson correlation coefficient between two equal-length series.

    Returns ``None`` when:
      - fewer than 3 data points (too few for meaningful correlation)
      - mismatched lengths
      - zero variance in either series (correlation mathematically undefined)

    Uses ``statistics.mean`` and ``statistics.stdev`` (sample) for numerical
    stability on small series.
    """
    n = len(xs)
    if n < 3 or n != len(ys):
        return None

    mx = mean(xs)
    my = mean(ys)

    try:
        sx = stdev(xs)
        sy = stdev(ys)
    except Exception:
        return None

    if sx == 0.0 or sy == 0.0:
        return None  # Zero variance → r undefined

    # r = cov(X,Y) / (sx * sy)  where cov uses n-1 denominator (sample)
    covariance = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / (n - 1)
    r = covariance / (sx * sy)

    # Clamp to [-1, 1] for floating-point safety
    return max(-1.0, min(1.0, r))


# ── Core functions ───────────────────────────────────────────────────

def compute_lens_correlation(
    score_history: List[Dict[str, float]],
) -> Dict[str, Any]:
    """Compute pairwise Pearson correlation between lens scores.

    Args:
        score_history: List of ``{lens_id: score}`` dicts accumulated across
            multiple ``dusun()`` calls.  Needs ≥ ``MIN_DATA_POINTS`` entries
            for correlation to be meaningful.

    Returns:
        Dict with keys:
          ``correlation_matrix``
              ``{lens_a|lens_b: r}`` — upper triangle only (21 pairs).
          ``kv7_flags``
              Lens pairs where ``|r| > KV7_THRESHOLD`` — potential KV₇
              independence violation.
          ``data_points``
              Number of score-history entries used.
          ``skipped``
              ``True`` if insufficient data; ``False`` otherwise.
    """
    if len(score_history) < MIN_DATA_POINTS:
        return {
            "correlation_matrix": {},
            "kv7_flags": [],
            "data_points": len(score_history),
            "skipped": True,
            "reason": f"insufficient data ({len(score_history)} < {MIN_DATA_POINTS})",
        }

    # Build per-lens score vectors
    lens_vectors: Dict[str, List[float]] = {lid: [] for lid in LENS_IDS}
    for entry in score_history:
        for lid in LENS_IDS:
            lens_vectors[lid].append(float(entry.get(lid, 0.0)))

    # Compute upper-triangle pairwise correlations
    matrix: Dict[str, Optional[float]] = {}
    kv7_flags: List[Dict[str, Any]] = []

    for i, lid_a in enumerate(LENS_IDS):
        for j in range(i + 1, len(LENS_IDS)):
            lid_b = LENS_IDS[j]
            r = _pearson_r(lens_vectors[lid_a], lens_vectors[lid_b])

            key = f"{lid_a}|{lid_b}"
            matrix[key] = round(r, 4) if r is not None else None

            # KV₇ flag: |r| > threshold suggests shared feature extraction
            if r is not None and abs(r) > KV7_THRESHOLD:
                kv7_flags.append({
                    "lens_a": lid_a,
                    "lens_b": lid_b,
                    "correlation": round(r, 4),
                    "concern": (
                        "high correlation suggests shared feature extraction — "
                        "potential KV₇ (İhlâs/independence) violation"
                    ),
                })

    return {
        "correlation_matrix": matrix,
        "kv7_flags": kv7_flags,
        "data_points": len(score_history),
        "skipped": False,
    }


def detect_tesanud(
    lens_scores: Dict[str, float],
    *,
    threshold: float = ACTIVITY_THRESHOLD,
    min_lenses: int = TESANUD_MIN_LENSES,
) -> Dict[str, Any]:
    """Detect super-additive convergence across independent lenses (AX63).

    **Tesanüd**: When ≥ ``min_lenses`` *independent* lenses each score above
    ``threshold``, their combined evidence is greater than the sum of parts.
    The framework treats this as *icmâ* (convergent testimony) — structurally
    stronger than numerical summation.

    Args:
        lens_scores: Current ``{lens_id: score}`` from a single ``dusun()``
            call.
        threshold: Minimum score for a lens to be considered "active".
        min_lenses: Minimum number of active lenses required for tesanüd.

    Returns:
        Dict with ``detected`` (bool), ``active_lenses``, ``active_count``.
    """
    active = [
        lid
        for lid in LENS_IDS
        if isinstance(lens_scores.get(lid), (int, float))
        and lens_scores[lid] > threshold
    ]

    return {
        "detected": len(active) >= min_lenses,
        "active_lenses": active,
        "active_count": len(active),
        "threshold": threshold,
        "min_lenses": min_lenses,
    }


def detect_infiradi(
    lens_scores: Dict[str, float],
    *,
    threshold: float = ACTIVITY_THRESHOLD,
) -> Dict[str, Any]:
    """Detect isolated finding across lenses (AX63 İnfirâd).

    **İnfirâd**: If exactly 1 lens scores above ``threshold``, that finding
    cannot compose with others.  The framework marks it as *infirâdî* —
    its evidence remains structurally isolated.

    Args:
        lens_scores: Current ``{lens_id: score}`` from a single ``dusun()``
            call.
        threshold: Minimum score for a lens to be considered "active".

    Returns:
        Dict with ``detected`` (bool), ``isolated_lens`` (str or None),
        ``active_count`` (int).
    """
    active = [
        lid
        for lid in LENS_IDS
        if isinstance(lens_scores.get(lid), (int, float))
        and lens_scores[lid] > threshold
    ]

    is_infiradi = len(active) == 1
    return {
        "detected": is_infiradi,
        "isolated_lens": active[0] if is_infiradi else None,
        "active_count": len(active),
        "threshold": threshold,
    }


# ── Main entry point ────────────────────────────────────────────────

def cross_validate(
    lens_scores: Dict[str, float],
    score_history: List[Dict[str, float]],
) -> Dict[str, Any]:
    """Full cross-validation pass: correlation + tesanüd + infirâd.

    This is the main entry point called from ``dusun()`` Step 4.5.
    It is **fire-and-forget** (KV₃ compliance): internal errors are caught
    and logged, returning a safe degraded dict.

    Args:
        lens_scores: Lens scores from the *current* ``dusun()`` call.
        score_history: All lens-score dicts accumulated across the session.

    Returns:
        Dict with ``correlation``, ``tesanud``, ``infiradi``,
        ``kv7_status`` keys.
    """
    try:
        correlation = compute_lens_correlation(score_history)
        tesanud = detect_tesanud(lens_scores)
        infiradi = detect_infiradi(lens_scores)

        return {
            "correlation": correlation,
            "tesanud": tesanud,
            "infiradi": infiradi,
            "kv7_status": "CONCERN" if correlation.get("kv7_flags") else "OK",
        }
    except Exception:
        logger.debug("cross_validate swallowed error", exc_info=True)
        return {
            "correlation": {"skipped": True, "reason": "internal error"},
            "tesanud": {"detected": False, "active_count": 0},
            "infiradi": {"detected": False, "active_count": 0},
            "kv7_status": "UNKNOWN",
        }
