"""Tests for Theorem Y — The Anti-Null Embedding Theorem.

Theorem Y: For a root system Φ ⊂ Rⁿ with BCC grid M in coords (a,b),
  M·(1,0,−1) = λ·(1,0,−1)  where  λ = N₊₊ − N₊₋

Classification:
  A_n (n≥2): λ = −1  (difference-type embedding, M[+,+]=0, M[+,−]=1)
  B_n, C_n:  λ = 0   (sign-flip symmetric)
  D_n (n≥4): λ = 0   (sign-flip symmetric)
  E_6,7,8:   λ = 0   (parity counting balance)
  F_4:       λ = 0   (mixed but balanced)
  G_2:       λ = −2  (two root orbits × (−1) each)

Key insight: anti-null is a property of the EMBEDDING, not the
abstract root system. D_3 ≅ A_3 but D_3→λ=0, A_3→λ=−1.
"""

import math
import numpy as np
import pytest

from watkins_nn.cascade import (
    null_eigenvalue,
    copositivity_counts,
    an_bcc_formula,
    is_antinull,
    antinull_classification,
    bcc_grid,
    _build_roots,
    _build_roots_extended,
    _g2_roots,
    an_bcc_charpoly,
    an_bcc_eigenvalues,
    an_bcc_perron,
    an_bcc_spectral_gap,
)


# ── A_n: eigenvalue −1 (difference-type) ──────────────────────────────

class TestAnDifferenceType:
    """A_n roots = e_i − e_j → M[+,+]=0, M[+,−]=1 → λ=−1."""

    @pytest.mark.parametrize("n", [2, 3, 4, 5, 6, 7, 8])
    def test_an_eigenvalue_minus_one(self, n):
        name = f"A{n}"
        assert null_eigenvalue(name) == -1

    @pytest.mark.parametrize("n", [2, 3, 4, 5, 6, 7, 8])
    def test_an_copositivity(self, n):
        """N₊₊ = 0 (impossible for differences), N₊₋ = 1 (unique e_0−e_1)."""
        npp, npm = copositivity_counts(f"A{n}")
        assert npp == 0, "No difference root can make two coords positive"
        assert npm == 1, "Unique root e_0 − e_1"

    @pytest.mark.parametrize("n", [2, 3, 4, 5, 6, 7, 8])
    def test_an_bcc_closed_form(self, n):
        """M = [[0, n−1, 1], [n−1, (n−1)(n−2), n−1], [1, n−1, 0]]."""
        formula = an_bcc_formula(n)
        computed = bcc_grid(f"A{n}")
        np.testing.assert_array_equal(computed, formula)

    def test_an_bcc_formula_eigenvalue(self):
        """Verify M@v = −v algebraically from the closed form for n=2..20."""
        v = np.array([1, 0, -1])
        for n in range(2, 21):
            M = an_bcc_formula(n)
            np.testing.assert_array_equal(M @ v, -v)

    @pytest.mark.parametrize("n", [2, 3, 4, 5, 6, 7, 8])
    def test_an_is_antinull(self, n):
        assert is_antinull(f"A{n}")

    def test_an_formula_raises_for_n1(self):
        with pytest.raises(ValueError):
            an_bcc_formula(1)


# ── D_n: eigenvalue 0 (sign-flip symmetric) ───────────────────────────

class TestDnSignFlip:
    """D_n has e_i + e_j roots → M[+,+] = M[+,−] → λ = 0."""

    @pytest.mark.parametrize("n", [4, 5, 6, 7, 8])
    def test_dn_eigenvalue_zero(self, n):
        assert null_eigenvalue(f"D{n}") == 0

    @pytest.mark.parametrize("n", [4, 5, 6, 7, 8])
    def test_dn_copositivity_balance(self, n):
        """Sign-flip symmetry forces N₊₊ = N₊₋."""
        npp, npm = copositivity_counts(f"D{n}")
        assert npp == npm

    @pytest.mark.parametrize("n", [4, 5, 6, 7, 8])
    def test_dn_not_antinull(self, n):
        assert not is_antinull(f"D{n}")


# ── D_3 ≅ A_3 isomorphism test ────────────────────────────────────────

class TestD3A3Isomorphism:
    """D_3 ≅ A_3 as abstract root systems but DIFFERENT embeddings."""

    def test_same_root_count(self):
        d3 = _build_roots("D3")
        a3 = _build_roots("A3")
        assert len(d3) == len(a3) == 12

    def test_different_eigenvalues(self):
        """The killer result: same abstract system, different embeddings."""
        assert null_eigenvalue("D3") == 0, "D_3 in R³ is null (sum-type)"
        assert null_eigenvalue("A3") == -1, "A_3 in R⁴ is anti-null (diff-type)"

    def test_d3_has_sum_roots(self):
        """D_3 contains e_0+e_1 (both coords positive)."""
        npp, _ = copositivity_counts("D3")
        assert npp >= 1, "D_3 must have co-positive roots"

    def test_a3_no_sum_roots(self):
        """A_3 has NO root with two coords positive."""
        npp, _ = copositivity_counts("A3")
        assert npp == 0


# ── B_n, C_n: eigenvalue 0 ────────────────────────────────────────────

class TestBnCn:
    """B_n and C_n have sign-flip symmetry → λ = 0."""

    @pytest.mark.parametrize("name,expected_size", [
        ("B3", 18), ("B4", 32), ("B5", 50),
        ("C3", 18), ("C4", 32),
    ])
    def test_root_count(self, name, expected_size):
        roots = _build_roots_extended(name)
        assert len(roots) == expected_size

    @pytest.mark.parametrize("name", ["B3", "B4", "B5", "C3", "C4"])
    def test_eigenvalue_zero(self, name):
        roots = _build_roots_extended(name)
        g = np.zeros((3, 3), dtype=int)
        for r in roots:
            sp0 = 0 if r[0] > 1e-10 else (2 if r[0] < -1e-10 else 1)
            sp1 = 0 if r[1] > 1e-10 else (2 if r[1] < -1e-10 else 1)
            g[sp0, sp1] += 1
        v = np.array([1, 0, -1])
        np.testing.assert_array_equal(g @ v, [0, 0, 0])


# ── E_6, E_7, E_8, F_4: eigenvalue 0 ──────────────────────────────────

class TestExceptional:
    """Exceptional root systems are null (parity counting balance / mixed)."""

    @pytest.mark.parametrize("name", ["E6", "E7", "E8", "F4"])
    def test_eigenvalue_zero(self, name):
        assert null_eigenvalue(name) == 0

    @pytest.mark.parametrize("name", ["E6", "E7", "E8", "F4"])
    def test_copositivity_balance(self, name):
        npp, npm = copositivity_counts(name)
        assert npp == npm

    def test_e8_type2_balance(self):
        """128 Type II (half-integer) roots: N₊₊ = N₊₋ = 32.

        Proof: fix r_0 = +½. For any (r_2,...,r_7) = ±½ pattern,
        exactly one of r_1 = ±½ gives even total minus count.
        So |{r_1=+½}| = |{r_1=−½}| = 32.
        """
        from watkins_nn.cascade import e8_roots
        R = e8_roots()
        type2 = R[np.all(np.abs(np.abs(R) - 0.5) < 1e-10, axis=1)]
        assert len(type2) == 128

        pp = np.sum((type2[:, 0] > 0) & (type2[:, 1] > 0))
        pm = np.sum((type2[:, 0] > 0) & (type2[:, 1] < 0))
        assert pp == 32
        assert pm == 32


# ── G_2: eigenvalue −2 ────────────────────────────────────────────────

class TestG2:
    """G_2 has two root orbits, each contributing −1 → total λ = −2."""

    def test_root_count(self):
        assert len(_g2_roots()) == 12

    def test_eigenvalue_minus_two(self):
        roots = _g2_roots()
        g = np.zeros((3, 3), dtype=int)
        for r in roots:
            sp0 = 0 if r[0] > 1e-10 else (2 if r[0] < -1e-10 else 1)
            sp1 = 0 if r[1] > 1e-10 else (2 if r[1] < -1e-10 else 1)
            g[sp0, sp1] += 1
        v = np.array([1, 0, -1])
        Mv = g @ v
        np.testing.assert_array_equal(Mv, [-2, 0, 2])

    def test_orbit_decomposition(self):
        """Short orbit (A_2 = differences) → −1, long orbit → −1."""
        from itertools import permutations

        short = [list(p) for p in set(permutations([1, -1, 0]))]
        long_roots = ([list(p) for p in set(permutations([2, -1, -1]))]
                      + [list(p) for p in set(permutations([-2, 1, 1]))])

        for label, roots in [("short", short), ("long", long_roots)]:
            g = np.zeros((3, 3), dtype=int)
            for r in roots:
                sp0 = 0 if r[0] > 0 else (2 if r[0] < 0 else 1)
                sp1 = 0 if r[1] > 0 else (2 if r[1] < 0 else 1)
                g[sp0, sp1] += 1
            lam = g[0, 0] - g[0, 2]
            assert lam == -1, f"G_2 {label} orbit should contribute −1"

    def test_g2_trace_zero(self):
        """All G_2 roots satisfy x + y + z = 0."""
        for r in _g2_roots():
            assert abs(sum(r)) < 1e-10


# ── The one-root theorem ──────────────────────────────────────────────

class TestOneRootFlip:
    """The single root e_0+e_1 (in D_n but not A_n) flips λ from −1 to 0."""

    def test_d4_restricted_to_differences(self):
        """Remove sum-type roots from D_4 → eigenvalue becomes −1."""
        d4_roots = _build_roots("D4")
        # Keep only roots where coord 0 and coord 1 have opposite signs
        # (or one is zero) — i.e., exclude roots with both same nonzero sign
        diff_only = [r for r in d4_roots
                     if not (r[0] > 0 and r[1] > 0)
                     and not (r[0] < 0 and r[1] < 0)]
        # Also include same-sign roots to restore full system minus the sum roots
        # Actually: A_n-type means e_i - e_j. From D_4, keep only roots where
        # exactly one of (coord 0, coord 1) is nonzero, OR they have opposite signs
        diff_only = [r for r in d4_roots
                     if not ((r[0] > 1e-10 and r[1] > 1e-10)
                             or (r[0] < -1e-10 and r[1] < -1e-10))]
        g = np.zeros((3, 3), dtype=int)
        for r in diff_only:
            sp0 = 0 if r[0] > 1e-10 else (2 if r[0] < -1e-10 else 1)
            sp1 = 0 if r[1] > 1e-10 else (2 if r[1] < -1e-10 else 1)
            g[sp0, sp1] += 1
        lam = g[0, 0] - g[0, 2]
        assert lam == -1, "Removing sum-type roots should give anti-null"

    def test_adding_one_sum_root_restores_null(self):
        """Start with A_3 (λ=−1). Add e_0+e_1 and −e_0−e_1 → λ becomes 0."""
        a3_roots = list(_build_roots("A3"))
        # Add the sum root pair that A_3 lacks but D_3 has
        extra = [(1, 1, 0, 0), (-1, -1, 0, 0)]
        augmented = a3_roots + extra
        g = np.zeros((3, 3), dtype=int)
        for r in augmented:
            sp0 = 0 if r[0] > 1e-10 else (2 if r[0] < -1e-10 else 1)
            sp1 = 0 if r[1] > 1e-10 else (2 if r[1] < -1e-10 else 1)
            g[sp0, sp1] += 1
        lam = g[0, 0] - g[0, 2]
        assert lam == 0, "Adding sum roots should restore null"


# ── Cascade connection ─────────────────────────────────────────────────

class TestCascadeConnection:
    """In E₈ cascade, D₃→A₂ layer is the unique anti-null layer (coords 0,2)."""

    def test_cascade_null_vector(self):
        """D₃→A₂ is anti-null in 2D marginal; all others null.

        D₄→D₃ is anti-null in coords (0,1) ONLY — but the cascade_chain
        grid_2d sums over coord 2 which cancels it (z=+ and z=- slices
        contribute equally). So in the 2D marginal, only D₃→A₂ survives.
        """
        from watkins_nn.cascade import cascade_chain
        chain = cascade_chain()
        for layer in chain:
            step = layer["step"]
            g = layer["grid_2d"]
            lam = int(g[0, 0] - g[0, 2])
            if step == "D3→A2":
                assert lam != 0, f"{step} should be anti-null"
            else:
                assert lam == 0, f"{step} should be null in 2D marginal"


# ── Full classification ───────────────────────────────────────────────

class TestFullClassification:
    """Verify the complete classification table."""

    def test_classification(self):
        c = antinull_classification()
        # A_n → −1
        for name in ["A2", "A3", "A4", "A5", "A6", "A7"]:
            assert c[name] == -1, f"{name} should be −1"
        # B_n → 0
        for name in ["B3", "B4", "B5"]:
            assert c[name] == 0, f"{name} should be 0"
        # C_n → 0
        for name in ["C3", "C4"]:
            assert c[name] == 0, f"{name} should be 0"
        # D_n → 0
        for name in ["D4", "D5", "D6"]:
            assert c[name] == 0, f"{name} should be 0"
        # E → 0
        for name in ["E6", "E7", "E8"]:
            assert c[name] == 0, f"{name} should be 0"
        # F_4 → 0
        assert c["F4"] == 0
        # G_2 → −2
        assert c["G2"] == -2

    def test_antinull_iff_nonzero(self):
        """Anti-null ⟺ λ ≠ 0 ⟺ {A_n, G_2}."""
        c = antinull_classification()
        for name, lam in c.items():
            if name.startswith("A") or name == "G2":
                assert lam != 0, f"{name} should be anti-null"
            else:
                assert lam == 0, f"{name} should be null"


# ── Eigenvector verification ──────────────────────────────────────────

class TestEigenvectorProperty:
    """Verify (1,0,−1) is always an eigenvector of the BCC grid."""

    @pytest.mark.parametrize("name", [
        "A2", "A3", "A4", "A5",
        "D4", "D5", "D6",
        "E6", "E7", "E8", "F4",
    ])
    def test_is_eigenvector(self, name):
        g = bcc_grid(name)
        v = np.array([1, 0, -1])
        Mv = g @ v
        # Mv should be proportional to v (or zero)
        if np.allclose(Mv, 0):
            return  # zero eigenvalue, trivially eigenvector
        # Check proportional: Mv[0] = -Mv[2] and Mv[1] = 0
        assert abs(Mv[1]) < 1e-10, "Middle component must be 0"
        assert abs(Mv[0] + Mv[2]) < 1e-10, "Must be antisymmetric"


# ── A_n BCC eigenvalue spectrum ───────────────────────────────────────

class TestAnBccSpectrum:
    """Eigenvalues of A_n BCC: {-1, [(n²-3n+3)±√Δ]/2}."""

    @pytest.mark.parametrize("n", [2, 3, 4, 5, 6, 7, 8, 9, 10])
    def test_eigenvalues_match_numpy(self, n):
        """Closed-form eigenvalues match numpy computation."""
        a = n - 1
        M = np.array([[0, a, 1], [a, a * (a - 1), a], [1, a, 0]], dtype=float)
        evals_np = sorted(np.linalg.eigvals(M).real, reverse=True)
        evals_formula = an_bcc_eigenvalues(n)
        np.testing.assert_allclose(
            sorted(evals_formula, reverse=True), evals_np, atol=1e-10
        )

    @pytest.mark.parametrize("n", [2, 3, 4, 5, 6, 7, 8])
    def test_null_eigenvalue_always_minus_one(self, n):
        """The second eigenvalue is always -1 (Theorem Y)."""
        _, lam_null, _ = an_bcc_eigenvalues(n)
        assert lam_null == -1.0

    @pytest.mark.parametrize("n", [2, 3, 4, 5, 6, 7, 8, 9, 10])
    def test_charpoly_sum_product(self, n):
        """Eigenvalue sum = n²-3n+3, product = -n(n-1)."""
        s, p, _ = an_bcc_charpoly(n)
        lam_plus, _, lam_minus = an_bcc_eigenvalues(n)
        assert abs((lam_plus + lam_minus) - s) < 1e-10
        assert abs(lam_plus * lam_minus - p) < 1e-10

    def test_golden_ratio_trace_zero(self):
        """trace(M) = n²-3n+1 vanishes at n = (3+√5)/2 = φ+1."""
        phi = (1 + math.sqrt(5)) / 2
        n_golden = phi + 1
        trace = n_golden ** 2 - 3 * n_golden + 1
        assert abs(trace) < 1e-12

    def test_discriminant_formula(self):
        """Δ = n⁴ - 6n³ + 19n² - 22n + 9 for n=2..15."""
        for n in range(2, 16):
            _, _, disc = an_bcc_charpoly(n)
            expected = n ** 4 - 6 * n ** 3 + 19 * n ** 2 - 22 * n + 9
            assert disc == expected

    def test_perron_root_growth(self):
        """Perron root grows as n² for large n."""
        for n in [10, 20, 50]:
            ratio = an_bcc_perron(n) / n ** 2
            assert 0.5 < ratio < 1.5, f"Perron root should be O(n²)"

    def test_spectral_gap_positive(self):
        """Spectral gap is positive for all n ≥ 2."""
        for n in range(2, 20):
            assert an_bcc_spectral_gap(n) > 0

    @pytest.mark.parametrize("n", [2, 3, 5, 10])
    def test_det_equals_n_times_n_minus_1(self, n):
        """det(A_n BCC) = n(n-1)."""
        a = n - 1
        M = np.array([[0, a, 1], [a, a * (a - 1), a], [1, a, 0]], dtype=float)
        det = np.linalg.det(M)
        assert abs(det - n * (n - 1)) < 1e-10
