"""
test_vpfm.py -- tests for the Vivanco Proximity-to-Failure Model reference impl.

Run:  pytest -q
"""

import math

import pytest

from vpfm import (
    BODYWEIGHT_FRACTION,
    LoadRepCurve,
    Params,
    Session,
    rir_interval,
    total_load,
)


# ---- calibration / static curve ------------------------------------------
def test_calibration_recovers_its_own_points():
    curve = LoadRepCurve.from_two_sets((225, 6), (185, 12))
    # the fitted curve must pass through both calibration points
    assert curve.fresh_reps(225) == pytest.approx(6, abs=1e-6)
    assert curve.fresh_reps(185) == pytest.approx(12, abs=1e-6)


def test_known_parameters():
    curve = LoadRepCurve.from_two_sets((225, 6), (185, 12))
    assert curve.tau == pytest.approx(30.66, abs=0.05)
    assert curve.L0 == pytest.approx(273.6, abs=0.3)
    assert curve.one_rep_max == pytest.approx(264.8, abs=0.5)


def test_one_rep_max_below_ceiling_and_yields_one_rep():
    curve = LoadRepCurve.from_two_sets((225, 6), (185, 12))
    assert curve.one_rep_max < curve.L0  # 1RM is below the zero-rep ceiling
    assert curve.fresh_reps(curve.one_rep_max) == pytest.approx(1.0, abs=1e-6)


def test_curve_strictly_decreasing_in_load():
    curve = LoadRepCurve.from_two_sets((225, 6), (185, 12))
    loads = [150, 175, 200, 225, 250]
    reps = [curve.fresh_reps(w) for w in loads]
    assert all(reps[i] > reps[i + 1] for i in range(len(reps) - 1))


def test_calibration_same_load_raises():
    with pytest.raises(ValueError):
        LoadRepCurve.from_two_sets((200, 5), (200, 8))


def test_calibration_inconsistent_raises():
    # heavier load allowing MORE reps is impossible -> should reject
    with pytest.raises(ValueError):
        LoadRepCurve.from_two_sets((225, 10), (185, 4))


# ---- fixed-load (bodyweight, Case A) -------------------------------------
def test_fixed_mode_returns_constant_and_ignores_load():
    bw = LoadRepCurve.fixed(12)
    assert bw.is_fixed
    assert bw.fresh_reps() == 12
    assert bw.fresh_reps(load=999) == 12  # load is ignored in fixed mode


def test_fixed_mode_has_no_one_rep_max():
    bw = LoadRepCurve.fixed(12)
    with pytest.raises(ValueError):
        _ = bw.one_rep_max


# ---- total effective load (Case B) ---------------------------------------
def test_total_load_pullup_is_bodyweight():
    assert total_load(175, BODYWEIGHT_FRACTION["pullup"], 0) == pytest.approx(175)


def test_total_load_pushup_fraction_and_added():
    assert total_load(200, BODYWEIGHT_FRACTION["pushup"], 0) == pytest.approx(128.0)
    assert total_load(200, BODYWEIGHT_FRACTION["pushup"], 20) == pytest.approx(148.0)


def test_total_load_excess_assistance_raises():
    with pytest.raises(ValueError):
        total_load(175, 1.0, added=-200)  # assistance exceeds bodyweight load


# ---- boundedness (Theorem 6.2, unconditional) ----------------------------
def test_capacity_stays_in_unit_interval_even_when_prediction_beaten():
    curve = LoadRepCurve.from_two_sets((225, 6), (185, 12))
    s = Session(curve, Params(k=0.4, gamma=2.0, t_rec=180.0))
    # deliberately beat the prediction: do more reps than the fresh max at 205
    over = math.ceil(curve.fresh_reps(205)) + 5
    s.do_set(reps=over, load=205, rest=0)
    for r in s.history:
        assert 0.0 < r.capacity_after_set <= 1.0
        assert 0.0 < r.capacity_after_rest <= 1.0
    # effort exceeded 1 and the model still produced a finite, bounded state
    assert s.history[0].effort > 1.0


def test_warmup_barely_costs_capacity():
    curve = LoadRepCurve.from_two_sets((225, 6), (185, 12))
    s = Session(curve, Params(k=0.4, gamma=2.0, t_rec=180.0))
    r = s.do_set(reps=5, load=135, rest=0)  # low-effort warm-up
    assert r.effort < 0.3
    assert r.capacity_after_set > 0.95  # warm-up costs only a few percent


# ---- fatigue dynamics ----------------------------------------------------
def test_identical_set_returns_less_reserve_when_fatigued():
    bw = LoadRepCurve.fixed(12)
    s = Session(bw, Params(k=0.4, gamma=2.0, t_rec=180.0))
    first = s.do_set(reps=8, rest=120)
    second = s.do_set(reps=8, rest=0)
    assert second.rir < first.rir  # the battery drained between them


def test_rir_matches_definition():
    curve = LoadRepCurve.from_two_sets((225, 6), (185, 12))
    s = Session(curve)
    r = s.do_set(reps=6, load=205, rest=0)
    assert r.rir == pytest.approx(r.fatigued_max - r.reps, abs=1e-9)


def test_worked_example_numbers():
    # reproduces the published Case A trace (pull-ups, fresh max 12)
    bw = LoadRepCurve.fixed(12)
    s = Session(bw, Params(k=0.4, gamma=2.0, t_rec=180.0))
    r1 = s.do_set(reps=8, rest=120)
    r2 = s.do_set(reps=8, rest=0)
    assert r1.rir == pytest.approx(4.00, abs=0.02)
    assert r1.capacity_after_rest == pytest.approx(0.916, abs=0.005)
    assert r2.rir == pytest.approx(3.00, abs=0.03)


# ---- parameter validation ------------------------------------------------
def test_bad_params_raise():
    with pytest.raises(ValueError):
        Params(k=1.5)
    with pytest.raises(ValueError):
        Params(gamma=0.5)
    with pytest.raises(ValueError):
        Params(t_rec=0)


# ---- Monte Carlo interval ------------------------------------------------
def test_interval_is_ordered_and_brackets_median():
    curve = LoadRepCurve.from_two_sets((225, 6), (185, 12))
    med, lo, hi = rir_interval(curve, reps=6, load=205, capacity=1.0, seed=7)
    assert lo <= med <= hi
    assert hi - lo > 0


def test_interval_widens_with_more_noise():
    curve = LoadRepCurve.from_two_sets((225, 6), (185, 12))
    _, lo_a, hi_a = rir_interval(curve, reps=6, load=205, noise_frac=0.05, seed=3)
    _, lo_b, hi_b = rir_interval(curve, reps=6, load=205, noise_frac=0.25, seed=3)
    assert (hi_b - lo_b) > (hi_a - lo_a)
