"""Tests for skillopt.optimizer.scheduler — edit-budget schedulers.

ReflACT trainers use an edit-budget scheduler at each optimisation step to
control how many skill edits are allowed (analogous to gradient clipping /
learning-rate annealing in neural-network training).

This module has zero LLM dependencies — all behaviour is deterministic pure
math — making it an ideal target for precise unit tests.

Scheduler contract
------------------
For multi-step runs, decay schedulers (Linear, Cosine) guarantee:
- First ``step()`` evaluates the first decay interval at
  ``t = 1 / total_steps`` (rounding can still produce ``max_lr``).
- ``total_steps``-th ``step()`` returns ``min_lr``.
- Intermediate values are monotonically non-increasing and clamped to
  ``[min_lr, max_lr]``.
- Beyond ``total_steps`` the value plateaus at ``min_lr``.
"""
from __future__ import annotations

import pytest

from skillopt.optimizer.scheduler import (
    LRScheduler,
    ConstantScheduler,
    LinearScheduler,
    CosineScheduler,
    AutonomousScheduler,
    build_scheduler,
)


# ── ConstantScheduler ────────────────────────────────────────────────────────


class TestConstantScheduler:
    """ConstantScheduler — fixed edit budget regardless of step."""

    def test_always_returns_max_lr(self) -> None:
        s = ConstantScheduler(max_lr=8, min_lr=2, total_steps=10)
        for _ in range(10):
            assert s.step() == 8

    def test_step_advances_internal_counter(self) -> None:
        s = ConstantScheduler(max_lr=5, min_lr=1, total_steps=5)
        assert s._current_step == 0
        s.step()
        assert s._current_step == 1
        s.step()
        assert s._current_step == 2

    def test_get_lr_returns_max_for_arbitrary_step(self) -> None:
        s = ConstantScheduler(max_lr=12, min_lr=1, total_steps=100)
        assert s.get_lr(1) == 12
        assert s.get_lr(50) == 12
        assert s.get_lr(999) == 12

    def test_state_dict_and_load_state_dict_round_trip(self) -> None:
        s = ConstantScheduler(max_lr=8, min_lr=2, total_steps=10)
        for _ in range(3):
            s.step()
        assert s._current_step == 3

        state = s.state_dict()
        s2 = ConstantScheduler(max_lr=8, min_lr=2, total_steps=10)
        s2.load_state_dict(state)
        assert s2._current_step == 3
        # Step after resume lands on the correct step
        assert s2.step() == 8
        assert s2._current_step == 4

    def test_load_state_dict_with_missing_key_defaults_to_zero(self) -> None:
        s = ConstantScheduler(max_lr=8, min_lr=2, total_steps=10)
        s.load_state_dict({})
        assert s._current_step == 0


# ── LinearScheduler ──────────────────────────────────────────────────────────


class TestLinearScheduler:
    """LinearScheduler — linear decay from max_lr to min_lr."""

    def test_first_step_uses_first_decay_interval(self) -> None:
        s = LinearScheduler(max_lr=10, min_lr=2, total_steps=10)
        assert s.step() == 9

    def test_last_step_returns_min_lr(self) -> None:
        s = LinearScheduler(max_lr=10, min_lr=2, total_steps=10)
        for _ in range(9):
            s.step()
        assert s.step() == 2

    def test_all_steps_return_integers(self) -> None:
        s = LinearScheduler(max_lr=8, min_lr=2, total_steps=6)
        for _ in range(6):
            lr = s.step()
            assert isinstance(lr, int)

    def test_total_steps_one_returns_max_lr(self) -> None:
        s = LinearScheduler(max_lr=10, min_lr=2, total_steps=1)
        assert s.step() == 10

    def test_total_steps_zero_returns_max_lr(self) -> None:
        """Degenerate case: 0-step training still gets max_lr on the one call."""
        s = LinearScheduler(max_lr=10, min_lr=2, total_steps=0)
        assert s.step() == 10

    def test_monotonically_non_increasing(self) -> None:
        s = LinearScheduler(max_lr=20, min_lr=2, total_steps=100)
        prev: int = 999
        for _ in range(100):
            lr = s.step()
            assert lr <= prev
            prev = lr

    def test_never_below_min_lr(self) -> None:
        s = LinearScheduler(max_lr=10, min_lr=2, total_steps=10)
        for _ in range(20):  # overshoot
            assert s.step() >= 2

    def test_never_above_max_lr(self) -> None:
        s = LinearScheduler(max_lr=10, min_lr=2, total_steps=10)
        for _ in range(20):
            assert s.step() <= 10

    def test_after_total_steps_stays_at_min_lr(self) -> None:
        s = LinearScheduler(max_lr=8, min_lr=2, total_steps=5)
        for _ in range(5):
            s.step()
        # Steps beyond total_steps should plateau at min_lr
        for _ in range(5):
            assert s.step() == 2

    def test_known_decay_sequence(self) -> None:
        """Linear decay max_lr=10, min_lr=2, total_steps=4.
           t = step/total_steps = 1/4, 1/2, 3/4, 1
           lr = 10 + (2-10)*t = 10 - 8t
           t=1/4: lr=8, t=1/2: lr=6, t=3/4: lr=4, t=1: lr=2
        """
        s = LinearScheduler(max_lr=10, min_lr=2, total_steps=4)
        assert s.step() == 8
        assert s.step() == 6
        assert s.step() == 4
        assert s.step() == 2

    def test_step_state_dict_resume_consistent(self) -> None:
        """After resume, the next step value is the same as without resume."""
        s1 = LinearScheduler(max_lr=10, min_lr=2, total_steps=5)
        for _ in range(3):
            s1.step()
        resumed_lr = s1.step()  # step 4

        s2 = LinearScheduler(max_lr=10, min_lr=2, total_steps=5)
        s2.load_state_dict({"current_step": 3})
        assert s2.step() == resumed_lr

    def test_max_lr_equals_min_lr_yields_constant(self) -> None:
        s = LinearScheduler(max_lr=5, min_lr=5, total_steps=10)
        for _ in range(10):
            assert s.step() == 5


# ── CosineScheduler ──────────────────────────────────────────────────────────


class TestCosineScheduler:
    """CosineScheduler — cosine annealing from max_lr to min_lr."""

    def test_first_step_uses_first_decay_interval(self) -> None:
        s = CosineScheduler(max_lr=100, min_lr=0, total_steps=4)
        assert s.step() == 85

    def test_last_step_returns_min_lr(self) -> None:
        s = CosineScheduler(max_lr=10, min_lr=2, total_steps=10)
        for _ in range(9):
            s.step()
        assert s.step() == 2

    def test_all_steps_return_integers(self) -> None:
        s = CosineScheduler(max_lr=8, min_lr=2, total_steps=6)
        for _ in range(6):
            lr = s.step()
            assert isinstance(lr, int)

    def test_total_steps_one_returns_max_lr(self) -> None:
        s = CosineScheduler(max_lr=10, min_lr=2, total_steps=1)
        assert s.step() == 10

    def test_total_steps_zero_returns_max_lr(self) -> None:
        s = CosineScheduler(max_lr=10, min_lr=2, total_steps=0)
        assert s.step() == 10

    def test_monotonically_non_increasing(self) -> None:
        s = CosineScheduler(max_lr=20, min_lr=2, total_steps=100)
        prev: int = 999
        for _ in range(100):
            lr = s.step()
            assert lr <= prev
            prev = lr

    def test_never_below_min_lr(self) -> None:
        s = CosineScheduler(max_lr=10, min_lr=2, total_steps=10)
        for _ in range(20):
            assert s.step() >= 2

    def test_never_above_max_lr(self) -> None:
        s = CosineScheduler(max_lr=10, min_lr=2, total_steps=10)
        for _ in range(20):
            assert s.step() <= 10

    def test_after_total_steps_stays_at_min_lr(self) -> None:
        s = CosineScheduler(max_lr=8, min_lr=2, total_steps=5)
        for _ in range(5):
            s.step()
        for _ in range(5):
            assert s.step() == 2

    def test_midpoint_close_to_mean(self) -> None:
        """At the half-way neighbourhood, cosine is close to (max+min)/2.
           total_steps=100, step=50 → t=50/100=0.5.
           cos(0.5π)=0, lr = (20+2)/2 = 11.
        """
        s = CosineScheduler(max_lr=20, min_lr=2, total_steps=100)
        for _ in range(49):
            s.step()
        mid = s.step()  # step 50
        assert mid == 11

    def test_step_state_dict_resume_consistent(self) -> None:
        s1 = CosineScheduler(max_lr=10, min_lr=2, total_steps=5)
        for _ in range(3):
            s1.step()
        resumed_lr = s1.step()

        s2 = CosineScheduler(max_lr=10, min_lr=2, total_steps=5)
        s2.load_state_dict({"current_step": 3})
        assert s2.step() == resumed_lr

    def test_max_lr_equals_min_lr_yields_constant(self) -> None:
        s = CosineScheduler(max_lr=5, min_lr=5, total_steps=10)
        for _ in range(10):
            assert s.step() == 5

    def test_early_steps_near_max(self) -> None:
        """Cosine annealing stays near max_lr early on."""
        s = CosineScheduler(max_lr=100, min_lr=0, total_steps=100)
        # step 1: t=0.01, cos(0.01π)≈0.9995 → lr≈99.98 → 100
        assert s.step() == 100
        # step 2: t=0.02, cos(0.02π)≈0.9980 → lr≈99.90 → 100
        assert s.step() == 100

    def test_known_decay_sequence(self) -> None:
        s = CosineScheduler(max_lr=10, min_lr=2, total_steps=4)
        assert [s.step() for _ in range(4)] == [9, 6, 3, 2]

    def test_late_steps_near_min(self) -> None:
        """Cosine annealing flattens near min_lr at the end (cos(π)=-1)."""
        s = CosineScheduler(max_lr=100, min_lr=0, total_steps=100)
        for _ in range(99):
            s.step()
        # step 100: t=1, cos(π)=-1 → lr=0
        assert s.step() == 0


# ── AutonomousScheduler ──────────────────────────────────────────────────────


class TestAutonomousScheduler:
    """AutonomousScheduler — no edit limit (model decides freely)."""

    def test_always_returns_no_limit(self) -> None:
        s = AutonomousScheduler(max_lr=8, min_lr=2, total_steps=10)
        for _ in range(20):
            assert s.step() == AutonomousScheduler.NO_LIMIT

    def test_step_advances_counter(self) -> None:
        s = AutonomousScheduler(max_lr=5, min_lr=1, total_steps=5)
        assert s._current_step == 0
        s.step()
        assert s._current_step == 1

    def test_get_lr_returns_no_limit(self) -> None:
        s = AutonomousScheduler(max_lr=5, min_lr=1, total_steps=10)
        assert s.get_lr(1) == AutonomousScheduler.NO_LIMIT
        assert s.get_lr(50) == AutonomousScheduler.NO_LIMIT

    def test_state_dict_round_trip(self) -> None:
        s = AutonomousScheduler(max_lr=5, min_lr=1, total_steps=10)
        for _ in range(4):
            s.step()
        s2 = AutonomousScheduler(max_lr=5, min_lr=1, total_steps=10)
        s2.load_state_dict(s.state_dict())
        assert s2._current_step == 4


# ── build_scheduler factory ──────────────────────────────────────────────────


class TestBuildScheduler:
    """build_scheduler factory — creates the right scheduler from a mode name."""

    def test_constant(self) -> None:
        s = build_scheduler("constant", max_lr=8, min_lr=2, total_steps=10)
        assert isinstance(s, ConstantScheduler)
        assert s.max_lr == 8
        assert s.min_lr == 2
        assert s.total_steps == 10

    def test_linear(self) -> None:
        s = build_scheduler("linear", max_lr=12, min_lr=3, total_steps=20)
        assert isinstance(s, LinearScheduler)

    def test_cosine(self) -> None:
        s = build_scheduler("cosine", max_lr=16, min_lr=4, total_steps=30)
        assert isinstance(s, CosineScheduler)

    def test_autonomous(self) -> None:
        s = build_scheduler("autonomous", max_lr=8, min_lr=2, total_steps=10)
        assert isinstance(s, AutonomousScheduler)

    def test_unknown_mode_raises_value_error(self) -> None:
        with pytest.raises(ValueError, match="Unknown scheduler mode"):
            build_scheduler("exponential", max_lr=8, min_lr=2, total_steps=10)

    def test_default_mode_is_constant(self) -> None:
        s = build_scheduler(max_lr=8, min_lr=2, total_steps=10)
        assert isinstance(s, ConstantScheduler)


# ── Abstract base class ──────────────────────────────────────────────────────


class TestLRSchedulerBase:
    """LRScheduler — abstract base: cannot be instantiated directly."""

    def test_cannot_instantiate_abstract(self) -> None:
        with pytest.raises(TypeError):
            LRScheduler(max_lr=8, min_lr=2, total_steps=10)  # type: ignore[abstract]

    def test_concrete_subclass_instantiates_fine(self) -> None:
        """ConstantScheduler (and others) work normally."""
        s = ConstantScheduler(max_lr=8, min_lr=2, total_steps=10)
        assert isinstance(s, LRScheduler)
        assert s.step() == 8
