"""calculus-integral@v1 π* tests (ticket #000030 Phase 3).

Covers indefinite integrals, definite integrals (with finite and
infinite limits), unevaluated-sentinel emission, timeout fallback,
and the JSON-shaped input contract. Whole file skips when SymPy is
absent.
"""

from __future__ import annotations

import json

import pytest

sympy = pytest.importorskip("sympy")  # entire file skipped if absent

from arborist.pi_star import get  # noqa: E402
from arborist.pi_star.protocol import PiStarError  # noqa: E402


@pytest.fixture
def ps():
    return get("calculus-integral@v1")


@pytest.fixture
def algebra():
    return get("algebra-symbolic@v1")


def _q(**kwargs) -> bytes:
    return json.dumps(kwargs).encode("utf-8")


# --- registry presence ------------------------------------------------


def test_registry_contains_calculus_integral():
    ps = get("calculus-integral@v1")
    assert ps.name == "calculus-integral"
    assert ps.version == "v1"
    assert ps.domain == "calculus"


# --- indefinite integrals ---------------------------------------------


@pytest.mark.parametrize(
    "f,x,expected",
    [
        ("x", "x", "x**2/2"),
        ("x**2", "x", "x**3/3"),
        ("sin(x)", "x", "-cos(x)"),
        ("cos(x)", "x", "sin(x)"),
        ("exp(x)", "x", "exp(x)"),
        ("1/x", "x", "log(x)"),
        ("1", "x", "x"),
    ],
)
def test_indefinite_integral_matches_expected(ps, algebra, f, x, expected):
    got = ps.canonicalize(_q(f=f, x=x))
    want = algebra.canonicalize(expected.encode())
    assert got == want


# --- definite integrals (finite limits) ------------------------------


@pytest.mark.parametrize(
    "f,x,a,b,expected",
    [
        ("x", "x", "0", "1", "1/2"),
        ("x**2", "x", "0", "1", "1/3"),
        ("sin(x)", "x", "0", "pi", "2"),
        ("1", "x", "0", "5", "5"),
    ],
)
def test_definite_integral_matches_expected(ps, algebra, f, x, a, b, expected):
    got = ps.canonicalize(_q(f=f, x=x, limits=[a, b]))
    want = algebra.canonicalize(expected.encode())
    assert got == want


# --- definite integrals with infinite limits -------------------------


def test_definite_integral_gaussian(ps, algebra):
    """∫_{-∞}^{∞} exp(-x²) dx = √π."""
    got = ps.canonicalize(_q(f="exp(-x**2)", x="x", limits=["-oo", "oo"]))
    want = algebra.canonicalize(b"sqrt(pi)")
    assert got == want


# --- unevaluated sentinel --------------------------------------------


def test_unevaluated_integral_emits_sentinel(ps):
    """Some integrands have no elementary closed form. SymPy returns
    an Integral object; this π* prefixes the canonical bytes with
    ``unevaluated:`` so callers can distinguish "no closed form"
    from "input invalid"."""
    out = ps.canonicalize(_q(f="exp(x)/log(x)", x="x"))
    assert out.startswith(b"unevaluated:")
    # Sentinel body is an Integral S-expression.
    body = out[len(b"unevaluated:"):]
    assert b"Integral" in body
    assert b"Symbol('x')" in body


def test_unevaluated_sentinel_is_deterministic(ps):
    """Same unevaluated input → same sentinel bytes."""
    a = ps.canonicalize(_q(f="exp(x)/log(x)", x="x"))
    b = ps.canonicalize(_q(f="exp(x)/log(x)", x="x"))
    assert a == b


# --- timeout fallback -------------------------------------------------


def test_timeout_fallback_emits_unevaluated_sentinel(ps, monkeypatch):
    """A timeout forces the worker to abort. The sentinel matches
    what SymPy would emit for the unevaluated form, so timeout and
    no-closed-form converge to the same bytes for the same input.
    (Equivalence-class invariant under cost variance.)

    We monkeypatch ``sp.integrate`` to sleep so the test is
    deterministic across hardware, rather than depending on a
    pathological input being slow enough on every CI runner.
    """
    import time as _time
    import arborist.pi_star.calculus_integral as mod

    real_integrate = mod.sp.integrate

    def slow_integrate(*args, **kwargs):
        _time.sleep(2.0)
        return real_integrate(*args, **kwargs)

    monkeypatch.setattr(mod.sp, "integrate", slow_integrate)

    out = ps.canonicalize(_q(f="x**2", x="x", timeout_seconds=0.05))
    assert out.startswith(b"unevaluated:")


# --- error paths ------------------------------------------------------


def test_non_bytes_input_raises(ps):
    with pytest.raises(PiStarError, match="expects bytes"):
        ps.canonicalize("not bytes")  # type: ignore[arg-type]


def test_non_json_input_raises(ps):
    with pytest.raises(PiStarError, match="JSON"):
        ps.canonicalize(b"not json")


def test_missing_f_raises(ps):
    with pytest.raises(PiStarError, match="missing field 'f'"):
        ps.canonicalize(_q(x="x"))


def test_missing_x_raises(ps):
    with pytest.raises(PiStarError, match="missing field 'x'"):
        ps.canonicalize(_q(f="x**2"))


def test_unparseable_f_raises(ps):
    with pytest.raises(PiStarError, match="cannot parse"):
        ps.canonicalize(_q(f="$$$ %% nope", x="x"))


def test_relational_f_rejected(ps):
    with pytest.raises(PiStarError, match="boolean/relational"):
        ps.canonicalize(_q(f="x > 0", x="x"))


def test_limits_must_be_two_element_list(ps):
    with pytest.raises(PiStarError, match="'limits'"):
        ps.canonicalize(_q(f="x", x="x", limits=[1]))
    with pytest.raises(PiStarError, match="'limits'"):
        ps.canonicalize(_q(f="x", x="x", limits="0,1"))


def test_unparseable_limits_raises(ps):
    with pytest.raises(PiStarError, match="limits"):
        ps.canonicalize(_q(f="x", x="x", limits=["$$%", 1]))


@pytest.mark.parametrize("bad_t", [0, -1, "1.0", True, False])
def test_timeout_must_be_positive_number(ps, bad_t):
    with pytest.raises(PiStarError, match="positive number"):
        ps.canonicalize(_q(f="x", x="x", timeout_seconds=bad_t))


# --- equivalence-class behavior --------------------------------------


def test_equivalent_integrands_collapse(ps):
    """∫(x+1)² dx ≡ ∫(x²+2x+1) dx — same closed form output."""
    a = ps.canonicalize(_q(f="(x+1)**2", x="x"))
    b = ps.canonicalize(_q(f="x**2 + 2*x + 1", x="x"))
    assert a == b


# --- composition / output is algebra-symbolic@v1 input ---------------


def test_closed_form_output_is_algebra_symbolic_input(ps, algebra):
    """A closed-form integral byte sequence is itself a valid
    algebra-symbolic@v1 input; canonicalizing it again is idempotent."""
    raw = ps.canonicalize(_q(f="x**2", x="x"))
    assert not raw.startswith(b"unevaluated:")
    once = algebra.canonicalize(raw)
    twice = algebra.canonicalize(once)
    assert once == twice
