"""Tests for bench.scripts.phi_alignment_probe (ticket #000034 §3.2).

Coverage matches the ticket's acceptance criteria:

- Determinism: same (W, H, seed) → identical ratio + verdict.
- Verdict thresholds: engineered cases (aligned / uniform / anti)
  land in their expected bucket; boundary cases land cleanly.
- Lanczos convergence: top-k eigenvalues from scipy.eigsh match
  a known dense decomposition on a synthetic Hessian.
- Module export shape: AlignmentReport dataclass round-trips via
  ``asdict``; ``PROBE_VERSION`` + thresholds exported.
- Input validation: shape mismatches, bad k, bad epsilon all raise.
- KAT regression: pinned (seed, dim, k, class) → expected_verdict
  triples replay against the synthetic-checkpoints.jsonl fixture.
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

# Module imports skip if numpy/scipy aren't installed (the [hessian]
# extras gate per #000034 §3 + pyproject.toml).
np = pytest.importorskip("numpy")
pytest.importorskip("scipy.sparse.linalg")

from bench.scripts.phi_alignment_probe import (
    ANTI_ALIGNED_RATIO_CEILING,
    DEFAULT_K_TOP,
    DEFAULT_K_BOT,
    PROBE_VERSION,
    STRUCTURAL_ALIGNMENT_RATIO_FLOOR,
    AlignmentReport,
    hessian_from_dense,
    measure_alignment,
)


# ----------------------------------------------------------- helpers


def _diagonal_h(dim_d: int, scale: float = 1.0):
    """Diagonal Hessian with eigenvalues 1..dim_d * scale.
    Eigenvectors are the canonical basis."""
    return np.diag(np.arange(1, dim_d + 1).astype(float) * scale)


def _aligned_w(dim_d: int, n_cols: int, low_k: int, seed: int):
    """W with mass concentrated on the bottom-low_k canonical-basis
    rows — i.e. the low-eigenvalue subspace of _diagonal_h."""
    rng = np.random.default_rng(seed)
    W = np.zeros((dim_d, n_cols))
    W[:low_k, :] = rng.standard_normal((low_k, n_cols))
    return W


def _uniform_w(dim_d: int, n_cols: int, seed: int):
    rng = np.random.default_rng(seed)
    return rng.standard_normal((dim_d, n_cols))


def _anti_w(dim_d: int, n_cols: int, high_k: int, seed: int):
    """W with mass concentrated on the top-high_k canonical-basis
    rows — i.e. the high-eigenvalue subspace."""
    rng = np.random.default_rng(seed)
    W = np.zeros((dim_d, n_cols))
    W[-high_k:, :] = rng.standard_normal((high_k, n_cols))
    return W


# ----------------------------------------------------------- determinism


def test_measure_alignment_deterministic_same_inputs():
    """Same inputs produce the same verdict + ratio within Lanczos
    float tolerance.

    Strict bit-equality fails because scipy.eigsh's Lanczos
    iteration uses a randomized starting vector internally; output
    differs in the ~15th decimal across runs even with identical
    user inputs. The verdict + alignment-class signal is stable —
    that's what callers depend on.
    """
    dim_d = 64
    W = _uniform_w(dim_d, 256, seed=0)
    H = _diagonal_h(dim_d)
    r1 = measure_alignment(W, hessian_from_dense(H), k_top=8, k_bot=8)
    r2 = measure_alignment(W, hessian_from_dense(H), k_top=8, k_bot=8)
    assert r1.verdict == r2.verdict
    assert r1.ratio == pytest.approx(r2.ratio, rel=1e-9)
    assert sorted(r1.eigenvalues_top) == pytest.approx(
        sorted(r2.eigenvalues_top), rel=1e-6
    )
    assert sorted(r1.eigenvalues_bot) == pytest.approx(
        sorted(r2.eigenvalues_bot), rel=1e-6
    )


# ----------------------------------------------------------- verdicts


def test_aligned_w_returns_structural_alignment():
    """W concentrated on low-λ subspace must produce
    STRUCTURAL_ALIGNMENT (ratio > 1.5)."""
    W = _aligned_w(256, 256, low_k=32, seed=42)
    H = _diagonal_h(256)
    r = measure_alignment(W, hessian_from_dense(H), k_top=32, k_bot=32)
    assert r.verdict == "STRUCTURAL_ALIGNMENT"
    assert r.ratio > STRUCTURAL_ALIGNMENT_RATIO_FLOOR


def test_uniform_w_returns_no_alignment():
    """W with isotropically-distributed entries must produce
    NO_ALIGNMENT (ratio near 1.0)."""
    W = _uniform_w(256, 256, seed=42)
    H = _diagonal_h(256)
    r = measure_alignment(W, hessian_from_dense(H), k_top=32, k_bot=32)
    assert r.verdict == "NO_ALIGNMENT"
    assert ANTI_ALIGNED_RATIO_CEILING <= r.ratio <= STRUCTURAL_ALIGNMENT_RATIO_FLOOR


def test_anti_aligned_w_returns_anti_aligned():
    """W concentrated on high-λ subspace must produce
    ANTI_ALIGNED (ratio < 0.7)."""
    W = _anti_w(256, 256, high_k=32, seed=42)
    H = _diagonal_h(256)
    r = measure_alignment(W, hessian_from_dense(H), k_top=32, k_bot=32)
    assert r.verdict == "ANTI_ALIGNED"
    assert r.ratio < ANTI_ALIGNED_RATIO_CEILING


# ----------------------------------------------------------- Lanczos convergence


def test_lanczos_top_k_matches_dense_decomposition():
    """Synthetic diagonal Hessian: top-k Lanczos eigenvalues must
    match the analytical top-k eigenvalues exactly (within tol)."""
    dim_d = 128
    H = _diagonal_h(dim_d)
    W = _uniform_w(dim_d, 256, seed=0)
    k = 16
    r = measure_alignment(W, hessian_from_dense(H), k_top=k, k_bot=k)

    # Diagonal H has eigenvalues 1..dim_d, so top-k = dim_d-k+1..dim_d
    expected_top = list(range(dim_d - k + 1, dim_d + 1))
    expected_bot = list(range(1, k + 1))

    # Lanczos returns eigenvalues in algebraic order; sort to match
    # canonical ordering. Allow small numerical tolerance.
    observed_top = sorted(r.eigenvalues_top)
    observed_bot = sorted(r.eigenvalues_bot)
    assert all(
        abs(o - e) < 1e-6 for o, e in zip(observed_top, expected_top)
    ), f"top: expected {expected_top}, got {observed_top}"
    assert all(
        abs(o - e) < 1e-6 for o, e in zip(observed_bot, expected_bot)
    ), f"bot: expected {expected_bot}, got {observed_bot}"


# ----------------------------------------------------------- module shape


def test_alignment_report_round_trips_via_asdict():
    W = _uniform_w(64, 32, seed=0)
    H = _diagonal_h(64)
    r = measure_alignment(W, hessian_from_dense(H), k_top=4, k_bot=4)
    d = r.as_dict()
    assert isinstance(d, dict)
    assert d["verdict"] in {
        "STRUCTURAL_ALIGNMENT", "NO_ALIGNMENT", "ANTI_ALIGNED",
    }
    assert isinstance(d["eigenvalues_top"], list)  # not tuple
    assert isinstance(d["eigenvalues_bot"], list)
    assert d["probe_version"] == PROBE_VERSION
    # JSON-serializable
    json.dumps(d)


def test_module_exports_thresholds_and_version():
    assert PROBE_VERSION == "phi-alignment-v1-lanczos"
    # versioned-default discipline (calculator-test-patterns.md §2):
    # "v1" substring present so future major-version rotations are
    # detectable at the call site without string-comparing module
    # paths.
    assert "v1" in PROBE_VERSION
    assert STRUCTURAL_ALIGNMENT_RATIO_FLOOR == 1.5
    assert ANTI_ALIGNED_RATIO_CEILING == 0.7
    assert DEFAULT_K_TOP == 100
    assert DEFAULT_K_BOT == 100


# ----------------------------------------------------------- monotonicity


def test_monotone_alignment_strength_in_concentration():
    """As W concentrates more mass on the low-λ subspace, the
    alignment ratio should strictly increase.

    Pattern from fox's `test_monotone_in_window_length` /
    `test_monotone_in_gradient_fraction`: scaling one knob while
    holding others fixed verifies the function responds in the
    expected direction. Catches sign errors + drops.
    """
    dim_d = 256
    H = _diagonal_h(dim_d)
    seed = 7

    # Three concentrations: spread (32 rows), tighter (16 rows),
    # tightest (8 rows). Tighter concentration on low-λ → larger
    # ratio. (Frobenius-norm-normalized so all three have same
    # ‖W‖_F.)
    rng = np.random.default_rng(seed)
    base_signal = rng.standard_normal((32, 256))

    def _make_w(low_k):
        W = np.zeros((dim_d, 256))
        # Reuse same total energy across concentrations.
        scale = (32 / low_k) ** 0.5
        W[:low_k, :] = base_signal[:low_k, :] * scale
        return W

    r_spread = measure_alignment(_make_w(32), hessian_from_dense(H), k_top=32, k_bot=32)
    r_medium = measure_alignment(_make_w(16), hessian_from_dense(H), k_top=32, k_bot=32)
    r_tight = measure_alignment(_make_w(8), hessian_from_dense(H), k_top=32, k_bot=32)

    assert r_spread.ratio < r_medium.ratio, (
        f"32-row vs 16-row: spread ratio {r_spread.ratio:.3f} should "
        f"be less than medium {r_medium.ratio:.3f}"
    )
    assert r_medium.ratio < r_tight.ratio, (
        f"16-row vs 8-row: medium ratio {r_medium.ratio:.3f} should "
        f"be less than tight {r_tight.ratio:.3f}"
    )


def test_monotone_alignment_in_dim_h():
    """Doubling W's column count (with the same row distribution)
    should leave the alignment ratio approximately invariant — the
    ratio is row-distribution-driven, not column-count-driven.

    Sanity invariant: A(W, H) is normalized by ‖W‖_F², so scaling
    column count (more samples of same row distribution) shouldn't
    move the ratio by more than sampling noise."""
    dim_d = 128
    H = _diagonal_h(dim_d)
    rng = np.random.default_rng(11)

    # Same row distribution, different number of columns.
    pattern = np.zeros(dim_d)
    pattern[:16] = 1.0
    W_128cols = rng.standard_normal((dim_d, 128)) * pattern[:, None]
    W_512cols = rng.standard_normal((dim_d, 512)) * pattern[:, None]

    r_128 = measure_alignment(W_128cols, hessian_from_dense(H), k_top=8, k_bot=8)
    r_512 = measure_alignment(W_512cols, hessian_from_dense(H), k_top=8, k_bot=8)

    # Tolerance is wide because column count affects sample noise.
    assert r_128.verdict == r_512.verdict, (
        f"verdict shouldn't change with column count: "
        f"128 cols → {r_128.verdict}, 512 cols → {r_512.verdict}"
    )


# ----------------------------------------------------------- hand-formula


def test_uniform_baseline_matches_analytical_formula():
    """The isotropic baseline ``a_uniform_bot`` in the
    AlignmentReport is computed analytically per #000034 §2.1
    derivation: ``E[A_bot(W_uniform, H)] = (1/dim_d) Σ 1/(λ_j+ε)``
    over the bot-k subset. Hand-compute and assert exact agreement.

    Pattern from fox's `test_b1_exact_formula` / `test_b2_exact_formula`:
    don't trust the implementation — derive the math in the test
    file from the spec, run the function, compare. Catches
    algorithm drift even when KAT regression passes.
    """
    dim_d = 64
    H = _diagonal_h(dim_d)  # eigenvalues 1..64 along canonical basis
    epsilon = 1e-6
    W = _uniform_w(dim_d, 32, seed=21)
    k = 8

    r = measure_alignment(W, hessian_from_dense(H), k_top=k, k_bot=k, epsilon=epsilon)

    # Hand-computed expected: bot-k eigenvalues are 1, 2, ..., k.
    expected_a_uniform_bot = (1.0 / dim_d) * sum(
        1.0 / (lam + epsilon) for lam in range(1, k + 1)
    )
    assert r.a_uniform == pytest.approx(expected_a_uniform_bot, abs=1e-9), (
        f"a_uniform mismatch: function {r.a_uniform}, "
        f"hand-computed {expected_a_uniform_bot}"
    )


def test_full_spectrum_a_top_plus_a_bot_covers_full_isotropic_baseline():
    """Closure invariant. When k_top + k_bot covers the full
    spectrum (k_top = k_bot = dim_d / 2), the function's a_top +
    a_bot should equal the full-spectrum alignment for any W.

    Pattern from fox's `test_total_equals_sum_of_three_contributions`:
    no missing term, no double-counting. Here the sum across all
    eigenvectors should reconstruct the full alignment.
    """
    dim_d = 32
    H = _diagonal_h(dim_d)
    W = _uniform_w(dim_d, 16, seed=33)
    k = dim_d // 2  # split the spectrum exactly

    r = measure_alignment(W, hessian_from_dense(H), k_top=k, k_bot=k)

    # Hand-compute full-spectrum A directly via dense decomposition.
    eigvals, eigvecs = np.linalg.eigh(H)
    proj = W.T @ eigvecs
    col_sq = np.sum(proj ** 2, axis=0)  # (dim_d,)
    weighted = col_sq / (eigvals + 1e-6)
    w_fro_sq = float(np.linalg.norm(W, "fro") ** 2)
    full_a = float(np.sum(weighted) / w_fro_sq)

    assert r.a_top + r.a_bot == pytest.approx(full_a, rel=1e-6), (
        f"a_top + a_bot ({r.a_top} + {r.a_bot} = "
        f"{r.a_top + r.a_bot}) should equal full-spectrum A "
        f"({full_a})"
    )


def test_eigenvalue_ordering_top_dominates_bot():
    """Closure invariant: top-k eigenvalues from Lanczos must all
    be ≥ all bot-k eigenvalues from Lanczos. Catches a bug where
    eigsh's 'LA'/'SA' modes returned overlapping ranges (which
    would happen on near-degenerate spectra without proper
    selection logic)."""
    dim_d = 64
    H = _diagonal_h(dim_d)
    W = _uniform_w(dim_d, 32, seed=44)
    r = measure_alignment(W, hessian_from_dense(H), k_top=8, k_bot=8)
    # Both lists are unordered Lanczos output; pick min/max.
    top_min = min(r.eigenvalues_top)
    bot_max = max(r.eigenvalues_bot)
    assert top_min >= bot_max, (
        f"top-k min {top_min} should be ≥ bot-k max {bot_max}"
    )


# ----------------------------------------------------------- validation (parametrized)


@pytest.mark.parametrize("bad_w_shape", [
    (16,),                        # 1-D
    (16, 16, 16),                 # 3-D
    (),                           # 0-D scalar
])
def test_rejects_wrong_dim_w(bad_w_shape):
    """Pattern from fox: parametrize over the shape-error cone."""
    H = _diagonal_h(16)
    W = np.zeros(bad_w_shape) if bad_w_shape else np.array(0.0)
    with pytest.raises(ValueError, match="2-D"):
        measure_alignment(W, hessian_from_dense(H), k_top=2, k_bot=2)


@pytest.mark.parametrize("bad_epsilon", [0, -1e-6, -1.0])
def test_rejects_non_positive_epsilon(bad_epsilon):
    W = _uniform_w(16, 32, seed=0)
    H = _diagonal_h(16)
    with pytest.raises(ValueError, match="epsilon"):
        measure_alignment(W, hessian_from_dense(H), k_top=2, k_bot=2, epsilon=bad_epsilon)


def test_rejects_dim_d_mismatch():
    W = np.zeros((16, 256))
    H = _diagonal_h(32)  # different dim
    with pytest.raises(ValueError, match="dim_d"):
        measure_alignment(W, hessian_from_dense(H), dim_d=32, k_top=4, k_bot=4)


def test_rejects_k_too_large():
    W = _uniform_w(8, 8, seed=0)
    H = _diagonal_h(8)
    with pytest.raises(ValueError, match="dim_d"):
        measure_alignment(W, hessian_from_dense(H), k_top=10, k_bot=10)


def test_rejects_zero_norm_w():
    W = np.zeros((16, 32))
    H = _diagonal_h(16)
    with pytest.raises(ValueError, match="Frobenius"):
        measure_alignment(W, hessian_from_dense(H), k_top=2, k_bot=2)


def test_hessian_from_dense_rejects_non_square():
    with pytest.raises(ValueError, match="square"):
        hessian_from_dense(np.zeros((4, 8)))


# ----------------------------------------------------------- KAT regression


KAT_FIXTURE = (
    Path(__file__).parent.parent
    / "bench" / "fixtures" / "phi-alignment"
    / "synthetic-checkpoints.jsonl"
)


@pytest.mark.skipif(
    not KAT_FIXTURE.exists(),
    reason="KAT fixture not yet generated",
)
def test_phi_alignment_known_answer_tests():
    """Replay the pinned synthetic-checkpoints.jsonl fixture; every
    entry's verdict must match what the probe produces today.

    Algorithm change (probe rewrite, threshold tuning, etc.) MUST
    bump PROBE_VERSION + emit a new fixture file under
    bench/fixtures/phi-alignment/ — old runs replay against old data.
    """
    cls_to_w_factory = {
        "aligned": lambda dim_d, n, low_k, seed: _aligned_w(dim_d, n, low_k, seed),
        "uniform": lambda dim_d, n, _low_k, seed: _uniform_w(dim_d, n, seed),
        "anti": lambda dim_d, n, high_k, seed: _anti_w(dim_d, n, high_k, seed),
    }
    n_kats = 0
    for line in KAT_FIXTURE.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        kat = json.loads(line)
        n_kats += 1
        cls = kat["class"]
        dim_d = int(kat["dim_d"])
        n_cols = int(kat["n_cols"])
        seed = int(kat["seed"])
        k_top = int(kat["k_top"])
        k_bot = int(kat["k_bot"])
        # All synthetic KATs use low_k = high_k = k_top = 32.
        W = cls_to_w_factory[cls](dim_d, n_cols, k_top, seed)
        H = _diagonal_h(dim_d)
        r = measure_alignment(
            W, hessian_from_dense(H),
            k_top=k_top, k_bot=k_bot,
        )
        assert r.verdict == kat["expected_verdict"], (
            f"KAT mismatch for {kat['label']!r}: "
            f"expected {kat['expected_verdict']}, got {r.verdict} "
            f"(ratio={r.ratio:.4f})"
        )
        # Float tolerance for the ratio match (Lanczos has minor
        # numerical noise across scipy versions).
        observed = round(r.ratio, 4)
        recorded = float(kat["observed_ratio"])
        assert abs(observed - recorded) < 0.05, (
            f"ratio drift for {kat['label']!r}: recorded {recorded}, "
            f"observed {observed}"
        )

    assert n_kats >= 30, f"KAT fixture seems incomplete: only {n_kats} entries"
