"""Unit tests for FSRS-6 spaced retrieval (pulse/brain/plasticity/spaced_retrieval.py).

Tests cover the core FSRS-6 math, boundary conditions, and the in-memory
fallback path (no DB required).
"""
import math
import time
import pytest

from pulse.brain.plasticity.spaced_retrieval import (
    _retrievability,
    _next_interval,
    _stability_recall,
    _stability_lapse,
    _difficulty_update,
    _schedule_item_in_memory,
    _mark_recalled_in_memory,
    _DESIRED_RETENTION,
    _FSRS_INITIAL_STAB,
    _FSRS_INITIAL_DIFF,
    _FSRS_MIN_STAB,
    _FSRS_MAX_STAB,
    _FSRS_MIN_DIFF,
    _FSRS_MAX_DIFF,
    _GRADE_SUCCESS,
    _GRADE_FAILURE,
    _MEM_SCHEDULE,
)


# ---------------------------------------------------------------------------
# _retrievability
# ---------------------------------------------------------------------------

class TestRetrievability:
    def test_zero_elapsed_returns_one(self):
        """Item reviewed immediately: R = 1."""
        assert _retrievability(0.0, 1.0) == pytest.approx(1.0)

    def test_at_due_date_returns_desired_retention(self):
        """At elapsed = _next_interval(S), R should equal desired retention."""
        S = 10.0
        t = _next_interval(S)
        R = _retrievability(t, S)
        assert R == pytest.approx(_DESIRED_RETENTION, abs=1e-4)

    def test_half_stability_elapsed_is_50_percent(self):
        """At elapsed = 9*S, R = (1+1)^-1 = 0.5 — the FSRS definition."""
        S = 5.0
        elapsed = 9.0 * S
        R = _retrievability(elapsed, S)
        assert R == pytest.approx(0.5, abs=1e-6)

    def test_large_elapsed_approaches_zero(self):
        """Very old items have near-zero retrievability."""
        R = _retrievability(10_000.0, 1.0)
        assert R < 0.01

    def test_zero_stability_returns_zero(self):
        assert _retrievability(10.0, 0.0) == 0.0

    def test_negative_stability_returns_zero(self):
        assert _retrievability(10.0, -1.0) == 0.0


# ---------------------------------------------------------------------------
# _next_interval
# ---------------------------------------------------------------------------

class TestNextInterval:
    def test_initial_stability_one_hour(self):
        """S=1h → next review in ~1h (R=0.9 at t=1h for S=1h)."""
        t = _next_interval(1.0)
        assert t == pytest.approx(1.0, rel=1e-3)

    def test_larger_stability_gives_larger_interval(self):
        assert _next_interval(24.0) > _next_interval(12.0)

    def test_floor_at_min_stability(self):
        """Zero stability clamps to _FSRS_MIN_STAB."""
        assert _next_interval(0.0) == _FSRS_MIN_STAB

    def test_ceiling_at_max_stability(self):
        """Extremely high stability clamps to _FSRS_MAX_STAB."""
        t = _next_interval(_FSRS_MAX_STAB * 100)
        assert t == _FSRS_MAX_STAB

    def test_proportional_to_stability(self):
        """Interval should scale linearly with stability."""
        ratio = _next_interval(10.0) / _next_interval(5.0)
        assert ratio == pytest.approx(2.0, rel=1e-3)


# ---------------------------------------------------------------------------
# _stability_recall (spacing effect)
# ---------------------------------------------------------------------------

class TestStabilityRecall:
    def test_spacing_effect_low_r_bigger_boost(self):
        """Recalled when almost forgotten (low R) → larger stability boost."""
        S, D = 10.0, 5.0
        S_high_r = _stability_recall(S, D, R=0.9)   # fresh recall
        S_low_r  = _stability_recall(S, D, R=0.3)   # almost forgotten
        assert S_low_r > S_high_r

    def test_success_always_increases_stability(self):
        """Any successful recall should increase stability."""
        for R in [0.1, 0.5, 0.9]:
            assert _stability_recall(5.0, 5.0, R) > 5.0

    def test_easy_item_grows_faster(self):
        """Low difficulty (D=1) gives larger stability growth than high (D=9)."""
        S, R = 5.0, 0.5
        assert _stability_recall(S, 1.0, R) > _stability_recall(S, 9.0, R)

    def test_result_capped_at_max_stability(self):
        """Result never exceeds _FSRS_MAX_STAB."""
        result = _stability_recall(_FSRS_MAX_STAB, 1.0, 0.01)
        assert result <= _FSRS_MAX_STAB

    def test_result_floored_at_min_stability(self):
        """Result never drops below _FSRS_MIN_STAB."""
        result = _stability_recall(0.001, 10.0, 0.99)
        assert result >= _FSRS_MIN_STAB


# ---------------------------------------------------------------------------
# _stability_lapse
# ---------------------------------------------------------------------------

class TestStabilityLapse:
    def test_lapse_reduces_stability_for_stable_item(self):
        """Forgetting a well-known item (high S) brings stability way down."""
        result = _stability_lapse(1000.0, 5.0)
        assert result < 1000.0

    def test_hard_item_loses_less(self):
        """High difficulty (D=9) → lapse stability slightly different from easy (D=1)."""
        # Both are valid; just ensure they produce different values
        r_easy = _stability_lapse(10.0, 1.0)
        r_hard = _stability_lapse(10.0, 9.0)
        assert r_easy != r_hard

    def test_result_floored_at_min_stability(self):
        result = _stability_lapse(0.0, 10.0)
        assert result >= _FSRS_MIN_STAB

    def test_result_capped_at_max_stability(self):
        result = _stability_lapse(_FSRS_MAX_STAB * 10, 1.0)
        assert result <= _FSRS_MAX_STAB


# ---------------------------------------------------------------------------
# _difficulty_update
# ---------------------------------------------------------------------------

class TestDifficultyUpdate:
    def test_success_decreases_difficulty(self):
        """Successful recall → item is getting easier."""
        d_new = _difficulty_update(5.0, _GRADE_SUCCESS)
        assert d_new < 5.0

    def test_failure_increases_difficulty(self):
        """Failed recall → item is harder than expected."""
        d_new = _difficulty_update(5.0, _GRADE_FAILURE)
        assert d_new > 5.0

    def test_floor_clamp(self):
        """Difficulty never drops below _FSRS_MIN_DIFF."""
        d_new = _difficulty_update(1.0, _GRADE_SUCCESS)
        assert d_new >= _FSRS_MIN_DIFF

    def test_ceiling_clamp(self):
        """Difficulty never exceeds _FSRS_MAX_DIFF."""
        d_new = _difficulty_update(10.0, _GRADE_FAILURE)
        assert d_new <= _FSRS_MAX_DIFF

    def test_symmetric_adjustments(self):
        """One failure after one success should not return exactly to start
        (asymmetric by design: failure penalizes more)."""
        D0 = 5.0
        D1 = _difficulty_update(D0, _GRADE_SUCCESS)
        D2 = _difficulty_update(D1, _GRADE_FAILURE)
        # Not exactly D0 — that's intentional
        assert D2 != pytest.approx(D0, abs=1e-6)


# ---------------------------------------------------------------------------
# In-memory fallback path (schedule + recall)
# ---------------------------------------------------------------------------

class TestInMemoryPath:
    def test_schedule_item_creates_record(self):
        _MEM_SCHEDULE.clear()
        rec = _schedule_item_in_memory("test_item_1", "lesson")
        assert rec["item_id"] == "test_item_1"
        assert rec["interval_hours"] == _FSRS_INITIAL_STAB
        assert rec["difficulty"] == _FSRS_INITIAL_DIFF
        assert rec["review_count"] == 0
        assert rec["next_review_ts"] > time.time()

    def test_success_increases_interval(self):
        _MEM_SCHEDULE.clear()
        rec0 = _schedule_item_in_memory("item_success", "lesson")
        initial_interval = rec0["interval_hours"]  # capture value before mutation
        _MEM_SCHEDULE["item_success"]["last_reviewed_ts"] = time.time() - 3600
        rec1 = _mark_recalled_in_memory("item_success", success=True)
        assert rec1["interval_hours"] > initial_interval

    def test_failure_resets_to_low_interval(self):
        _MEM_SCHEDULE.clear()
        rec0 = _schedule_item_in_memory("item_fail", "lesson")
        _MEM_SCHEDULE["item_fail"]["interval_hours"] = 100.0  # simulate grown interval
        _MEM_SCHEDULE["item_fail"]["last_reviewed_ts"] = time.time() - 100 * 3600
        rec1 = _mark_recalled_in_memory("item_fail", success=False)
        assert rec1["interval_hours"] < 100.0

    def test_review_count_increments(self):
        _MEM_SCHEDULE.clear()
        _schedule_item_in_memory("item_count", "lesson")
        rec = _mark_recalled_in_memory("item_count", success=True)
        assert rec["review_count"] == 1
        rec = _mark_recalled_in_memory("item_count", success=True)
        assert rec["review_count"] == 2

    def test_difficulty_decreases_on_repeated_success(self):
        _MEM_SCHEDULE.clear()
        _schedule_item_in_memory("item_diff", "lesson")
        initial_diff = _FSRS_INITIAL_DIFF
        rec = _mark_recalled_in_memory("item_diff", success=True)
        assert rec["difficulty"] < initial_diff

    def test_difficulty_increases_on_failure(self):
        _MEM_SCHEDULE.clear()
        _schedule_item_in_memory("item_diff_fail", "lesson")
        rec = _mark_recalled_in_memory("item_diff_fail", success=False)
        assert rec["difficulty"] > _FSRS_INITIAL_DIFF

    def test_unknown_item_auto_registers(self):
        _MEM_SCHEDULE.clear()
        rec = _mark_recalled_in_memory("brand_new_item", success=True)
        assert rec["review_count"] == 1
        assert "brand_new_item" in _MEM_SCHEDULE

    def test_stability_bounded(self):
        """Repeated successes should not push stability past max."""
        _MEM_SCHEDULE.clear()
        _schedule_item_in_memory("item_bound", "pattern")
        _MEM_SCHEDULE["item_bound"]["interval_hours"] = _FSRS_MAX_STAB * 0.9
        _MEM_SCHEDULE["item_bound"]["difficulty"] = _FSRS_MIN_DIFF
        _MEM_SCHEDULE["item_bound"]["last_reviewed_ts"] = time.time() - _FSRS_MAX_STAB * 0.9 * 3600
        rec = _mark_recalled_in_memory("item_bound", success=True)
        assert rec["interval_hours"] <= _FSRS_MAX_STAB
