"""
Tests for the VITA package (ablevlabs.vita).

Replaces the old vpfm-era suite. These lock in the behavior verified during the
package reorg: calibration, the VPFM capacity trajectory, read-only prediction,
rendering, and backward-compatible aliases.
"""

import math

import pytest

from ablevlabs import vita


# --------------------------------------------------------------------------
# fixtures
# --------------------------------------------------------------------------
@pytest.fixture
def bench():
    return vita.calibrate(
        weight1=315, reps1=5, weight2=275, reps2=9, name="Bench Press"
    )


# --------------------------------------------------------------------------
# package surface
# --------------------------------------------------------------------------
def test_public_api_present():
    expected = {
        "calibrate",
        "calibrate_bodyweight",
        "Lift",
        "Workout",
        "Prediction",
        "LoggedSet",
        "Plan",
        "WorkoutSummary",
        "LoadRepCurve",
        "Params",
        "Session",
        "SetResult",
        "total_load",
        "rir_interval",
        "BODYWEIGHT_FRACTION",
    }
    assert expected <= set(dir(vita))


def test_version():
    assert vita.__version__ == "0.5.0"


# --------------------------------------------------------------------------
# calibration
# --------------------------------------------------------------------------
def test_calibration_curve(bench):
    assert bench.one_rep_max == pytest.approx(360.8, abs=0.1)
    assert bench.fresh_reps(275) == pytest.approx(9.0, abs=0.01)


def test_calibrate_tuple_form_matches_kwargs():
    a = vita.calibrate(weight1=315, reps1=5, weight2=275, reps2=9)
    b = vita.calibrate((315, 5), (275, 9))
    assert a.one_rep_max == pytest.approx(b.one_rep_max)


def test_inconsistent_calibration_raises():
    # heavier set allowing MORE reps is impossible -> must reject
    with pytest.raises(ValueError, match="inconsistent calibration"):
        vita.calibrate(weight1=110, reps1=28, weight2=90, reps2=22)


def test_bodyweight_calibration():
    pull = vita.calibrate_bodyweight(20, name="Pull-ups")
    assert pull.is_bodyweight
    assert pull.fresh_reps() == 20


# --------------------------------------------------------------------------
# prediction is read-only
# --------------------------------------------------------------------------
def test_predict_does_not_mutate_state(bench):
    w = vita.Workout(bench)
    r1 = w.predict_set(weight=295, reps=5).rir
    r2 = w.predict_set(weight=295, reps=5).rir
    assert r1 == r2
    assert w.capacity == 1.0  # untouched by predicting


def test_predicted_rir_value(bench):
    w = vita.Workout(bench)
    assert w.predict_set(weight=295, reps=5).rir == pytest.approx(1.932, abs=0.01)


def test_target_reps(bench):
    w = vita.Workout(bench)
    assert w.target_reps(weight=275, rir=2) == 7


# --------------------------------------------------------------------------
# the VPFM capacity trajectory (the heart of the model)
# --------------------------------------------------------------------------
def test_capacity_trajectory(bench):
    w = vita.Workout(bench)
    assert w.capacity == 1.0
    w.log_set(weight=275, reps=7)
    assert w.capacity == pytest.approx(0.921, abs=0.001)
    w.log_set(weight=275, reps=7)
    assert w.capacity == pytest.approx(0.887, abs=0.001)


def test_fatigue_is_monotonic_within_session(bench):
    # capacity should never rise across same-rest, same-load working sets
    w = vita.Workout(bench)
    caps = []
    for _ in range(5):
        w.log_set(weight=275, reps=6)
        caps.append(w.capacity)
    assert all(caps[i] <= caps[i - 1] for i in range(1, len(caps)))


# --------------------------------------------------------------------------
# rendering (coaching layer)
# --------------------------------------------------------------------------
def test_prediction_renders(bench):
    s = vita.Workout(bench).predict_set(weight=295, reps=5).summary()
    assert "prediction" in s and "Predicted RIR" in s


def test_plan_renders_and_has_rows(bench):
    plan = vita.Workout(bench).build_plan(weight=275, target_rir=2, sets=5)
    assert len(plan.rows) == 5
    assert "Session plan" in plan.summary()


def test_workout_summary_renders(bench):
    w = vita.Workout(bench)
    w.log_set(weight=275, reps=7)
    w.log_set(weight=275, reps=6)
    assert "Workout summary" in w.summary().summary()


def test_summary_requires_a_logged_set(bench):
    with pytest.raises(ValueError, match="no sets logged"):
        vita.Workout(bench).summary()


# --------------------------------------------------------------------------
# backward-compatible aliases (old vpfm method names still work)
# --------------------------------------------------------------------------
def test_backcompat_aliases(bench):
    w = vita.Workout(bench)
    assert isinstance(w.do_set(load=275, reps=7), vita.LoggedSet)
    assert isinstance(w.reps_for_rir(2, load=275), int)
    assert isinstance(w.predicted_rir(5, load=295), float)


# --------------------------------------------------------------------------
# engine surface
# --------------------------------------------------------------------------
def test_total_load_combines_bodyweight_and_added():
    w = vita.total_load(200, vita.BODYWEIGHT_FRACTION["pullup"], added=45)
    assert w == pytest.approx(245)


def test_rir_interval_brackets_median(bench):
    med, lo, hi = vita.rir_interval(bench.curve, reps=5, load=295, seed=1)
    assert lo < med < hi


# --------------------------------------------------------------------------
# Phase 1: coaching layer (classification, recommendations, no float leaks)
# --------------------------------------------------------------------------
import re


def test_repr_does_not_leak_full_floats(bench):
    w = vita.Workout(bench)
    w.log_set(weight=275, reps=7)
    for obj in (
        w.predict_set(weight=295, reps=5),
        w.log_set(weight=275, reps=7),
        w.build_plan(weight=275, target_rir=2, sets=3),
        w.summary(),
    ):
        # no raw float with many decimals (e.g. 26.841391130219577) in the repr
        assert not re.search(r"\d+\.\d{4,}", repr(obj)), repr(obj)


def test_classification_labels():
    label = lambda r: vita.coaching.classify(r).label
    assert label(6) == "Warm-up"
    assert label(2) == "Hard working set"
    assert label(1) == "Near failure"
    assert label(0) == "Failure"
    assert label(-1) == "Past failure"


def test_rpe_is_ten_minus_rir():
    assert vita.coaching.classify(2).rpe == 8
    assert vita.coaching.classify(0).rpe == 10
    assert vita.coaching.classify(-2).rpe == 10  # clamped at 10


def test_result_objects_expose_coaching(bench):
    pred = vita.Workout(bench).predict_set(weight=295, reps=5)
    assert pred.status == "Hard working set"
    assert pred.rpe == 8
    assert isinstance(pred.advice, str) and pred.advice
    assert pred.coaching.zone == "working"


def test_summary_includes_rpe_and_coach(bench):
    s = vita.Workout(bench).predict_set(weight=295, reps=5).summary()
    assert "RPE" in s and "Coach:" in s


# --------------------------------------------------------------------------
# Phase 1 (cont.): output modes + immediate feedback
# --------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_output_mode():
    # keep the global mode from leaking between tests
    yield
    vita.set_output("summary")


def test_default_mode_is_summary():
    assert vita.get_output() == "summary"


def test_set_output_rejects_unknown_mode():
    with pytest.raises(ValueError):
        vita.set_output("loud")


def test_quiet_mode_repr_is_one_line(bench):
    ls = vita.Workout(bench).log_set(weight=275, reps=8)
    vita.set_output("quiet")
    r = repr(ls)
    assert "\n" not in r and "Coach:" not in r and r.startswith("<LoggedSet")


def test_summary_mode_repr_is_the_block(bench):
    ls = vita.Workout(bench).log_set(weight=275, reps=8)
    vita.set_output("summary")
    assert "Coach:" in repr(ls)


def test_developer_mode_exposes_raw_numbers(bench):
    ls = vita.Workout(bench).log_set(weight=275, reps=8)
    vita.set_output("developer")
    assert re.search(r"\d+\.\d{4,}", repr(ls))  # full precision on purpose


def test_print_always_shows_summary_regardless_of_mode(bench):
    ls = vita.Workout(bench).log_set(weight=275, reps=8)
    vita.set_output("quiet")
    assert "Coach:" in str(ls)  # print() uses __str__ -> summary


def test_per_workout_output_override(bench):
    wq = vita.Workout(bench, output="quiet")
    ls = wq.log_set(weight=275, reps=8)
    assert "\n" not in repr(ls)  # this workout is quiet
    assert vita.get_output() == "summary"  # global is untouched


def test_immediate_feedback_helpers(bench):
    w = vita.Workout(bench)
    assert w.last_set is None and w.last_recommendation is None
    w.log_set(weight=275, reps=8)
    assert isinstance(w.last_set, vita.LoggedSet)
    assert isinstance(w.last_recommendation, str) and w.last_recommendation


def test_finish_returns_summary(bench):
    w = vita.Workout(bench)
    w.log_set(weight=275, reps=8)
    assert isinstance(w.finish(), vita.WorkoutSummary)


# --------------------------------------------------------------------------
# Phase 2: calibration intelligence
# --------------------------------------------------------------------------
def test_calibrate_attaches_a_report(bench):
    assert bench.report is not None
    assert 0.0 <= bench.quality <= 1.0
    assert bench.confidence in ("high", "medium", "low")


def test_builder_is_chainable_and_counts_sets():
    c = vita.Calibration("Squat").add_set(405, 3).add_set(315, 10)
    assert c.n_sets == 2
    assert isinstance(c.fit(), vita.Lift)


def test_builder_requires_two_sets():
    with pytest.raises(ValueError, match="at least two"):
        vita.Calibration("X").add_set(225, 5).fit()


def test_multi_point_fit_is_higher_confidence():
    sq = (
        vita.Calibration("Squat")
        .add_set(405, 3)
        .add_set(315, 10)
        .add_set(225, 18)
        .fit()
    )
    assert sq.confidence == "high"
    assert sq.report.r_squared is not None  # R² defined for 3+ sets
    assert sq.report.excluded == ()  # clean data, nothing dropped


def test_two_point_builder_matches_calibrate():
    a = vita.calibrate(weight1=315, reps1=5, weight2=275, reps2=9)
    b = vita.Calibration("X").add_set(315, 5).add_set(275, 9).fit()
    assert a.one_rep_max == pytest.approx(b.one_rep_max)


def test_outlier_strict_raises_and_names_the_set():
    c = vita.Calibration("DL").add_set(495, 3).add_set(405, 9).add_set(315, 30)
    with pytest.raises(ValueError, match="315"):  # the bad set, not the good ones
        c.fit()  # strict=True


def test_outlier_nonstrict_excludes_and_fits():
    c = vita.Calibration("DL").add_set(495, 3).add_set(405, 9).add_set(315, 30)
    with pytest.warns(UserWarning, match="excluded"):
        dl = c.fit(strict=False)
    assert isinstance(dl, vita.Lift)
    assert len(dl.report.excluded) == 1
    assert dl.report.excluded[0][0] == 315  # the dropped set


def test_inconsistent_data_raises_in_builder():
    c = vita.Calibration("X").add_set(110, 28).add_set(90, 22)  # heavier -> more reps
    with pytest.raises(ValueError, match="inconsistent"):
        c.fit()


def test_single_load_raises():
    c = vita.Calibration("X").add_set(225, 8).add_set(225, 8)
    with pytest.raises(ValueError, match="single load|equal"):
        c.fit()


def test_close_loads_warn_low_confidence():
    with pytest.warns(UserWarning, match="low-confidence"):
        vita.calibrate(weight1=225, reps1=8, weight2=220, reps2=9)


def test_bodyweight_has_no_quality():
    pull = vita.calibrate_bodyweight(20, name="Pull-ups")
    assert pull.quality is None and pull.confidence is None


def test_lift_repr_shows_confidence(bench):
    assert "confidence" in repr(bench) and "1RM" in repr(bench)


# --------------------------------------------------------------------------
# Phase 3: the planner (monotonicity fix + modes)
# --------------------------------------------------------------------------
def test_no_plan_prescribes_more_reps_on_a_later_set(bench):
    # the doc-6 bug: rounding inside the sim loop made later sets ask for MORE reps
    for tr in (0, 1, 2, 3):
        for w in (225, 255, 275, 295):
            reps = [
                r[2]
                for r in vita.Workout(bench)
                .build_plan(weight=w, target_rir=tr, sets=8)
                .rows
            ]
            assert reps == sorted(reps, reverse=True), (w, tr, reps)


def test_plan_mode_defaults_to_rir(bench):
    plan = vita.Workout(bench).build_plan(weight=275, target_rir=2, sets=4)
    assert plan.mode == "rir"
    assert "Session plan" in plan.summary()


def test_fixed_mode_holds_reps_and_drops_rir(bench):
    plan = vita.Workout(bench).build_plan(weight=275, reps=8, sets=5, mode="fixed")
    reps = [r[2] for r in plan.rows]
    rirs = [r[3] for r in plan.rows]
    assert all(x == 8 for x in reps)  # reps held constant
    assert rirs[-1] < rirs[0]  # RIR falls as fatigue accumulates


def test_fixed_mode_requires_reps(bench):
    with pytest.raises(ValueError, match="fixed mode needs reps"):
        vita.Workout(bench).build_plan(weight=275, sets=5, mode="fixed")


def test_topset_mode_top_then_backoff(bench):
    plan = vita.Workout(bench).build_plan(
        weight=300, target_rir=1, sets=4, mode="topset", backoff_pct=12
    )
    weights = [r[1] for r in plan.rows]
    assert weights[0] == 300  # top set at full weight
    assert all(w < 300 for w in weights[1:])  # back-offs are lighter
    assert len(set(weights[1:])) == 1  # all back-offs same load


def test_topset_requires_weighted_lift():
    pull = vita.calibrate_bodyweight(20, name="Pull-ups")
    with pytest.raises(ValueError, match="weighted lift"):
        vita.Workout(pull).build_plan(target_rir=2, sets=4, mode="topset")


def test_invalid_plan_mode_raises(bench):
    with pytest.raises(ValueError, match="mode must be"):
        vita.Workout(bench).build_plan(weight=275, sets=4, mode="pyramid")


def test_plan_to_frame_has_rir_column(bench):
    pytest.importorskip("pandas")
    df = vita.Workout(bench).build_plan(weight=275, target_rir=2, sets=3).to_frame()
    assert list(df.columns) == ["set", "weight", "reps", "rir"]
    assert len(df) == 3


# --------------------------------------------------------------------------
# Phase 5: the help system
# --------------------------------------------------------------------------
def test_help_is_callable_but_not_exported():
    assert callable(vita.help)
    assert (
        "help" not in vita.__all__
    )  # so `from vita import *` won't shadow builtin help


def test_help_overview_prints(capsys):
    assert vita.help() is None
    out = capsys.readouterr().out
    assert "VITA" in out and vita.__version__ in out and "Quickstart" in out


def test_help_topic_prints_focused_section(capsys):
    vita.help("planning")
    out = capsys.readouterr().out
    assert "Planning" in out and "topset" in out


def test_help_unknown_topic_lists_options(capsys):
    vita.help("nope")
    out = capsys.readouterr().out
    assert "No help topic" in out and "calibration" in out


# --------------------------------------------------------------------------
# Phase 4: fitting the fatigue model
# --------------------------------------------------------------------------
import numpy as np
from ablevlabs.vita.core import Session as _Session


def _make_sessions(
    lift,
    true,
    n_sessions=16,
    n_sets=7,
    noise=0.005,
    seed=0,
    targets=(1.0, 2.0, 3.0),
    rests=(60.0, 150.0, 300.0, 450.0),
    loads=(225.0, 245.0, 265.0, 285.0),
):
    rng = np.random.default_rng(seed)
    sessions = []
    for _ in range(n_sessions):
        eng = _Session(lift.curve, true, capacity=1.0)
        sets = []
        for _ in range(n_sets):
            load = float(rng.choice(loads))
            rest = float(rng.choice(rests))
            target = float(rng.choice(targets))
            reps = max(1, round(eng.reps_for_rir(target, load=load)))
            res = eng.do_set(reps=reps, load=load, rest=rest)
            rir = res.rir + rng.normal(0, noise * res.fatigued_max)
            sets.append(vita.ObservedSet(load, reps, rir, rest))
        sessions.append(sets)
    return sessions


def test_observed_session_builder():
    sess = vita.observed_session([(275, 8, 1.5, 200), (275, 7, 0.5)])
    assert len(sess) == 2
    assert sess[0].rest == 200 and sess[1].rest == 180.0  # default rest
    assert sess[1].rir == 0.5


def test_fit_recovers_params_under_low_noise(bench):
    true = vita.Params(k=0.52, gamma=2.0, t_rec=260.0)
    fit = vita.fit_fatigue(bench, _make_sessions(bench, true, noise=0.005, seed=3))
    assert abs(fit.params.k - 0.52) < 0.07
    assert abs(fit.params.t_rec - 260) < 45
    assert set(fit.fitted) == {"k", "t_rec"}  # rest varied, gamma not requested


def test_fit_validates_against_defaults(bench):
    true = vita.Params(k=0.55, gamma=2.0, t_rec=280.0)  # differs from the defaults
    fit = vita.fit_fatigue(bench, _make_sessions(bench, true, noise=0.01, seed=5))
    assert fit.validated and fit.improvement > 0


def test_t_rec_held_when_rest_constant(bench):
    true = vita.Params(k=0.5, gamma=2.0, t_rec=200.0)
    fit = vita.fit_fatigue(bench, _make_sessions(bench, true, rests=(180.0,), seed=1))
    assert "t_rec" not in fit.fitted
    assert fit.params.t_rec == vita.Params().t_rec  # held at default


def test_gamma_only_fitted_on_request_with_effort_spread(bench):
    true = vita.Params(k=0.5, gamma=2.0, t_rec=220.0)
    wide = _make_sessions(bench, true, targets=(0.0, 2.0, 4.0), seed=2)  # wide effort
    assert "gamma" not in vita.fit_fatigue(bench, wide).fitted  # not unless asked
    assert "gamma" in vita.fit_fatigue(bench, wide, fit_gamma=True).fitted


def test_fit_requires_enough_transitions(bench):
    with pytest.raises(ValueError, match="transitions"):
        vita.fit_fatigue(
            bench, [vita.observed_session([(275, 8, 2.0)])]
        )  # 0 transitions


def test_fitted_params_plug_into_workout(bench):
    true = vita.Params(k=0.5, gamma=2.0, t_rec=220.0)
    fit = vita.fit_fatigue(bench, _make_sessions(bench, true, seed=4))
    w = vita.Workout(bench, params=fit.params)
    assert isinstance(w.log_set(weight=275, reps=7), vita.LoggedSet)


def test_fatiguefit_repr_and_summary(bench):
    true = vita.Params(k=0.52, gamma=2.0, t_rec=260.0)
    fit = vita.fit_fatigue(bench, _make_sessions(bench, true, seed=6))
    assert "FatigueFit" in repr(fit) and "Fatigue fit" in fit.summary()


# ---------------------------------------------------------------------------
# vita.info() — the plain-English function guide
# ---------------------------------------------------------------------------
def test_info_is_exported_and_callable():
    assert callable(vita.info)
    assert "info" in vita.__all__  # safe to export (no builtin clash, unlike help)


def test_info_full_list_prints(capsys):
    assert vita.info() is None
    out = capsys.readouterr().out
    assert vita.__version__ in out
    # a spread of tools from different groups all appear
    for name in ("vita.calibrate", "w.log_set", "w.build_plan", "vita.fit_fatigue"):
        assert name in out
    # group headers render
    assert "Set up a lift" in out and "Plan your sets" in out


def test_info_lookup_zooms_in(capsys):
    assert vita.info("log_set") is None
    out = capsys.readouterr().out
    assert "w.log_set" in out
    assert "weight=275" in out  # the example line
    assert "gives:" in out  # the what-you-get-back line


def test_info_lookup_matches_multiple(capsys):
    vita.info("calibrate")
    out = capsys.readouterr().out
    assert "vita.calibrate(" in out and "vita.calibrate_bodyweight(" in out


def test_info_lookup_strips_vita_prefix(capsys):
    vita.info("vita.fit_fatigue")  # user pastes the full dotted name
    out = capsys.readouterr().out
    assert "vita.fit_fatigue" in out and "FatigueFit" in out


def test_info_unknown_name_is_graceful(capsys):
    vita.info("zzz")
    out = capsys.readouterr().out
    assert "No vita tool matching" in out and "vita.info()" in out


def test_help_overview_points_to_info(capsys):
    vita.help()
    out = capsys.readouterr().out
    assert "vita.info()" in out  # discoverability: help -> info


# ---------------------------------------------------------------------------
# vita.tour() — the guided walkthrough
# ---------------------------------------------------------------------------
def test_tour_is_exported_and_callable():
    assert callable(vita.tour)
    assert "tour" in vita.__all__


def test_tour_scripted_runs_end_to_end(capsys):
    # interactive=False must never touch input(); it runs on the sample lift.
    assert vita.tour(interactive=False) is None
    out = capsys.readouterr().out
    # it walks all five steps and the real commands
    assert "Welcome to vita" in out
    for step in (
        "Step 1 of 5",
        "Step 2 of 5",
        "Step 3 of 5",
        "Step 4 of 5",
        "Step 5 of 5",
    ):
        assert step in out
    for cmd in (
        "vita.calibrate(",
        "vita.Workout(lift)",
        "w.predict_set(",
        "w.target_reps(",
        "w.log_set(",
        "w.build_plan(",
    ):
        assert cmd in out
    assert "That's the whole loop" in out


def test_tour_falls_back_to_scripted_without_stdin(capsys, monkeypatch):
    # if input() blows up (no stdin), the tour must degrade gracefully, not crash
    def boom(*_a, **_k):
        raise EOFError

    monkeypatch.setattr("builtins.input", boom)
    vita.tour(interactive=True)  # asks for input, gets EOFError
    out = capsys.readouterr().out
    assert "That's the whole loop" in out  # still finished the whole walkthrough


def test_help_and_info_point_to_tour(capsys):
    vita.help()
    assert "vita.tour()" in capsys.readouterr().out
    vita.info()
    assert "vita.tour()" in capsys.readouterr().out


# ---------------------------------------------------------------------------
# build_plan ramp mode (a weight per set)
# ---------------------------------------------------------------------------
def _bench_lift():
    return vita.calibrate(weight1=315, reps1=5, weight2=275, reps2=9, name="Bench")


def test_ramp_weights_and_reps():
    w = vita.Workout(_bench_lift())
    plan = w.build_plan(weights=[135, 175, 225, 275], reps=[8, 6, 4, 3])
    assert plan.mode == "ramp"
    assert [row[1] for row in plan.rows] == [135, 175, 225, 275]  # a weight per set
    assert [row[2] for row in plan.rows] == [8, 6, 4, 3]  # the reps we gave
    assert len(plan.rows) == 4


def test_ramp_weights_only_fills_to_target_rir():
    w = vita.Workout(_bench_lift())
    plan = w.build_plan(weights=[225, 245, 265], target_rir=2)
    assert len(plan.rows) == 3
    assert all(abs(row[3] - 2) < 1e-6 for row in plan.rows)  # each set at target RIR
    reps = [row[2] for row in plan.rows]
    assert reps == sorted(reps, reverse=True)  # heavier -> fewer reps


def test_ramp_reps_scalar_applies_to_all():
    w = vita.Workout(_bench_lift())
    plan = w.build_plan(weights=[185, 205, 225], reps=5)
    assert [row[2] for row in plan.rows] == [5, 5, 5]


def test_ramp_length_mismatch_raises():
    w = vita.Workout(_bench_lift())
    with pytest.raises(ValueError):
        w.build_plan(weights=[135, 185, 225], reps=[8, 6])


def test_single_weight_plan_unchanged():
    w = vita.Workout(_bench_lift())
    plan = w.build_plan(weight=275, target_rir=2, sets=3)
    assert plan.mode == "rir"
    assert len(plan.rows) == 3
    assert all(row[1] == 275 for row in plan.rows)


def test_plan_headers_have_no_em_dash():
    w = vita.Workout(_bench_lift())
    for p in (
        w.build_plan(weight=275, target_rir=2, sets=2),
        w.build_plan(weights=[135, 225], reps=[8, 4]),
    ):
        assert "\u2014" not in p.summary()
