"""Tests for #000030 Phase 4-7 π* graduations.

Phase 4 — calculus-limit@v1
Phase 5 — calculus-series@v1
Phase 6 — linear-algebra@v1
Phase 7 — function-sampled@v1

All four gate on SymPy via the [math] extra; tests skip cleanly
when sympy is absent (mirrors the algebra-symbolic / calculus-
derivative pattern).
"""

from __future__ import annotations

import pytest

from arborist.pi_star import PiStarError, get


sympy = pytest.importorskip("sympy")


# ===== calculus-limit@v1 (Phase 4) ========================================


def test_limit_sinx_over_x_at_zero_is_one():
    ps = get("calculus-limit@v1")
    out = ps.canonicalize(b'{"f":"sin(x)/x","x":"x","point":"0"}')
    assert out == sympy.srepr(sympy.Integer(1)).encode("utf-8")


def test_limit_polynomial_at_finite_point():
    ps = get("calculus-limit@v1")
    out = ps.canonicalize(b'{"f":"x**2 + 1","x":"x","point":"3"}')
    # 9 + 1 = 10
    assert out == sympy.srepr(sympy.Integer(10)).encode("utf-8")


def test_limit_one_over_x_at_zero_plus_is_oo():
    ps = get("calculus-limit@v1")
    out = ps.canonicalize(b'{"f":"1/x","x":"x","point":"0","dir":"+"}')
    assert out == b"+oo"


def test_limit_one_over_x_at_zero_minus_is_minus_oo():
    ps = get("calculus-limit@v1")
    out = ps.canonicalize(b'{"f":"1/x","x":"x","point":"0","dir":"-"}')
    assert out == b"-oo"


def test_limit_at_infinity():
    ps = get("calculus-limit@v1")
    out = ps.canonicalize(b'{"f":"1/x","x":"x","point":"oo"}')
    assert out == sympy.srepr(sympy.Integer(0)).encode("utf-8")


def test_limit_invalid_dir_raises():
    ps = get("calculus-limit@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(b'{"f":"x","x":"x","point":"0","dir":"invalid"}')


def test_limit_missing_f_raises():
    ps = get("calculus-limit@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(b'{"x":"x","point":"0"}')


# ===== calculus-series@v1 (Phase 5) =======================================


def test_series_sinx_maclaurin_n4():
    """Taylor series of sin(x) at x=0 to 4 terms is x - x³/6."""
    ps = get("calculus-series@v1")
    out = ps.canonicalize(b'{"f":"sin(x)","x":"x","x0":"0","n":4}')
    # Should canonicalize to x - x³/6 via expand+srepr.
    expected = sympy.srepr(sympy.expand(
        sympy.Symbol("x") - sympy.Rational(1, 6) * sympy.Symbol("x") ** 3
    )).encode("utf-8")
    assert out == expected


def test_series_exp_maclaurin_n3():
    """exp(x) Taylor at 0, n=3 → 1 + x + x²/2."""
    ps = get("calculus-series@v1")
    out = ps.canonicalize(b'{"f":"exp(x)","x":"x","x0":"0","n":3}')
    x = sympy.Symbol("x")
    expected = sympy.srepr(sympy.expand(
        1 + x + sympy.Rational(1, 2) * x ** 2
    )).encode("utf-8")
    assert out == expected


def test_series_n_must_be_positive():
    ps = get("calculus-series@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(b'{"f":"sin(x)","x":"x","x0":"0","n":0}')


def test_series_n_must_be_int():
    ps = get("calculus-series@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(b'{"f":"sin(x)","x":"x","x0":"0","n":3.5}')


def test_series_missing_field_raises():
    ps = get("calculus-series@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(b'{"f":"sin(x)","x":"x","n":4}')


# ===== linear-algebra@v1 (Phase 6) ========================================


def test_linalg_det_2x2():
    ps = get("linear-algebra@v1")
    # det [[1,2],[3,4]] = 1*4 - 2*3 = -2
    out = ps.canonicalize(b'{"op":"det","matrix":[[1,2],[3,4]]}')
    assert out == b"det:-2/1"


def test_linalg_det_3x3():
    ps = get("linear-algebra@v1")
    # Identity 3x3 has det 1.
    out = ps.canonicalize(
        b'{"op":"det","matrix":[[1,0,0],[0,1,0],[0,0,1]]}'
    )
    assert out == b"det:1/1"


def test_linalg_rref_collapses_dependent_rows():
    """RREF of [[2,4],[1,2]] is [[1,2],[0,0]] — one pivot."""
    ps = get("linear-algebra@v1")
    out = ps.canonicalize(b'{"op":"rref","matrix":[[2,4],[1,2]]}')
    assert out == b"rref;rows=2;cols=2:1/1|2/1||0/1|0/1"


def test_linalg_inverse_2x2():
    ps = get("linear-algebra@v1")
    # [[2,0],[0,2]]^-1 = [[1/2,0],[0,1/2]]
    out = ps.canonicalize(b'{"op":"inverse","matrix":[[2,0],[0,2]]}')
    assert out == b"inverse;rows=2;cols=2:1/2|0/1||0/1|1/2"


def test_linalg_eigenvalues_diagonal():
    """Diagonal matrix has its diagonal entries as eigenvalues."""
    ps = get("linear-algebra@v1")
    out = ps.canonicalize(
        b'{"op":"eigenvalues","matrix":[[3,0],[0,2]]}'
    )
    # Sorted by srepr → 2 first then 3.
    assert out == b"eigenvalues:2/1x1|3/1x1"


def test_linalg_inverse_singular_raises():
    """Singular matrix has no inverse."""
    ps = get("linear-algebra@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(b'{"op":"inverse","matrix":[[1,1],[1,1]]}')


def test_linalg_det_non_square_raises():
    ps = get("linear-algebra@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(b'{"op":"det","matrix":[[1,2,3],[4,5,6]]}')


def test_linalg_unknown_op_raises():
    ps = get("linear-algebra@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(b'{"op":"transpose","matrix":[[1,2],[3,4]]}')


def test_linalg_jagged_matrix_raises():
    ps = get("linear-algebra@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(b'{"op":"det","matrix":[[1,2],[3,4,5]]}')


def test_linalg_cell_format_folds():
    """1, 1.0, '1.0', '1/1' all sympify to the same SymPy Integer/Rational
    so the canonical bytes for det should match."""
    ps = get("linear-algebra@v1")
    a = ps.canonicalize(b'{"op":"det","matrix":[[1,2],[3,4]]}')
    b = ps.canonicalize(b'{"op":"det","matrix":[[1.0,2.0],[3.0,4.0]]}')
    c = ps.canonicalize(b'{"op":"det","matrix":[["1","2"],["3","4"]]}')
    assert a == b == c


# ===== function-sampled@v1 (Phase 7) ======================================


def test_function_sampled_basic_polynomial():
    """x² sampled at 0,1,2,3,4 with dv=1 → 0|1|4|9|16."""
    ps = get("function-sampled@v1")
    out = ps.canonicalize(
        b'{"f":"x**2","x":"x","x_min":0,"x_max":4,"n_samples":5,"dv":1}'
    )
    assert out == b"dt=1;dv=1;n=5;t0=0:0|1|4|9|16"


def test_function_sampled_equivalent_expressions_collapse():
    """sin(x) and 2*sin(x)/2 are textually different but evaluate
    identically. Same canonical bytes."""
    ps = get("function-sampled@v1")
    a = ps.canonicalize(
        b'{"f":"sin(x)","x":"x","x_min":0,"x_max":1,"n_samples":11,"dv":0.01}'
    )
    b = ps.canonicalize(
        b'{"f":"2*sin(x)/2","x":"x","x_min":0,"x_max":1,"n_samples":11,"dv":0.01}'
    )
    assert a == b


def test_function_sampled_different_grid_distinct():
    """Different sample grid → different canonical."""
    ps = get("function-sampled@v1")
    a = ps.canonicalize(
        b'{"f":"x","x":"x","x_min":0,"x_max":4,"n_samples":5,"dv":1}'
    )
    b = ps.canonicalize(
        b'{"f":"x","x":"x","x_min":0,"x_max":4,"n_samples":9,"dv":1}'
    )
    assert a != b


def test_function_sampled_output_format_byte_compatible_with_time_series():
    """Output starts with the same 'dt=...;dv=...;n=...;t0=...:' header
    as time-series-quantized@v1 so storage paths can treat both
    interchangeably."""
    ps = get("function-sampled@v1")
    out = ps.canonicalize(
        b'{"f":"x","x":"x","x_min":0,"x_max":2,"n_samples":3,"dv":1}'
    ).decode("utf-8")
    assert out.startswith("dt=")
    assert ";dv=" in out
    assert ";n=" in out
    assert ";t0=0:" in out


def test_function_sampled_x_max_must_exceed_x_min():
    ps = get("function-sampled@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(
            b'{"f":"x","x":"x","x_min":1,"x_max":1,"n_samples":3,"dv":1}'
        )


def test_function_sampled_n_samples_minimum_two():
    ps = get("function-sampled@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(
            b'{"f":"x","x":"x","x_min":0,"x_max":1,"n_samples":1,"dv":1}'
        )


def test_function_sampled_dv_must_be_positive():
    ps = get("function-sampled@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(
            b'{"f":"x","x":"x","x_min":0,"x_max":1,"n_samples":3,"dv":0}'
        )


def test_function_sampled_complex_value_raises():
    """sqrt(x) at negative x is complex; should raise rather than
    silently drop the imaginary part."""
    ps = get("function-sampled@v1")
    with pytest.raises(PiStarError):
        ps.canonicalize(
            b'{"f":"sqrt(x)","x":"x","x_min":-1,"x_max":1,"n_samples":3,"dv":0.1}'
        )
