"""Tests for v0.39.0 Part B — ReLoRA callback."""

from __future__ import annotations

from unittest.mock import MagicMock

import pytest
from pydantic import ValidationError

from soup_cli.config.schema import TrainingConfig


class TestReLoRASchema:
    def test_default_disabled(self):
        cfg = TrainingConfig()
        assert cfg.relora_steps is None
        assert cfg.relora_warmup_ratio == 0.1
        assert cfg.relora_reset_optimizer is True
        assert 0.0 < cfg.relora_prune_ratio <= 1.0

    def test_relora_steps_positive(self):
        cfg = TrainingConfig(relora_steps=500)
        assert cfg.relora_steps == 500

    def test_relora_steps_rejects_zero(self):
        with pytest.raises(ValidationError):
            TrainingConfig(relora_steps=0)

    def test_relora_steps_rejects_negative(self):
        with pytest.raises(ValidationError):
            TrainingConfig(relora_steps=-1)

    def test_relora_steps_upper_bound(self):
        # Cap at 10**7 to prevent overflow / nonsensical values
        with pytest.raises(ValidationError):
            TrainingConfig(relora_steps=10**8)

    def test_relora_warmup_ratio_bounds(self):
        TrainingConfig(relora_warmup_ratio=0.0)
        TrainingConfig(relora_warmup_ratio=1.0)
        with pytest.raises(ValidationError):
            TrainingConfig(relora_warmup_ratio=-0.01)
        with pytest.raises(ValidationError):
            TrainingConfig(relora_warmup_ratio=1.01)

    def test_relora_prune_ratio_bounds(self):
        TrainingConfig(relora_prune_ratio=0.5)
        TrainingConfig(relora_prune_ratio=0.99)
        with pytest.raises(ValidationError):
            TrainingConfig(relora_prune_ratio=0.0)
        with pytest.raises(ValidationError):
            TrainingConfig(relora_prune_ratio=1.0)
        with pytest.raises(ValidationError):
            TrainingConfig(relora_prune_ratio=1.01)


class TestReLoRAPolicy:
    def test_policy_frozen(self):
        from dataclasses import FrozenInstanceError

        from soup_cli.utils.relora import ReLoRAPolicy
        p = ReLoRAPolicy(steps=500, warmup_ratio=0.1, reset_optimizer=True, prune_ratio=0.9)
        with pytest.raises(FrozenInstanceError):
            p.steps = 999  # type: ignore

    def test_policy_should_fire_step_zero_no(self):
        from soup_cli.utils.relora import ReLoRAPolicy
        p = ReLoRAPolicy(steps=500)
        assert p.should_fire(global_step=0) is False

    def test_policy_should_fire_at_multiple(self):
        from soup_cli.utils.relora import ReLoRAPolicy
        p = ReLoRAPolicy(steps=500)
        assert p.should_fire(global_step=500) is True
        assert p.should_fire(global_step=1000) is True

    def test_policy_should_fire_skips_warmup(self):
        from soup_cli.utils.relora import ReLoRAPolicy
        # warmup_ratio=0.5 over total 1000 = first 500 skipped
        p = ReLoRAPolicy(steps=200, warmup_ratio=0.5)
        assert p.should_fire(global_step=200, total_steps=1000) is False
        assert p.should_fire(global_step=600, total_steps=1000) is True

    def test_policy_rejects_invalid_steps(self):
        from soup_cli.utils.relora import ReLoRAPolicy
        with pytest.raises(ValueError):
            ReLoRAPolicy(steps=0)
        with pytest.raises(ValueError):
            ReLoRAPolicy(steps=-1)

    def test_policy_rejects_invalid_prune_ratio(self):
        from soup_cli.utils.relora import ReLoRAPolicy
        with pytest.raises(ValueError):
            ReLoRAPolicy(steps=500, prune_ratio=0.0)
        with pytest.raises(ValueError):
            ReLoRAPolicy(steps=500, prune_ratio=1.0)
        with pytest.raises(ValueError):
            ReLoRAPolicy(steps=500, prune_ratio=1.5)


class TestMagnitudePrune:
    def test_magnitude_prune_zeroes_low_magnitude(self):
        try:
            import torch
        except ImportError:
            pytest.skip("torch not available")
        from soup_cli.utils.relora import magnitude_prune_tensor

        x = torch.tensor([0.01, 0.02, 0.5, 1.0, 2.0])
        # prune_ratio=0.6 → keep top 40% (2 of 5) → smallest 3 zeroed
        out = magnitude_prune_tensor(x.clone(), prune_ratio=0.6)
        nonzero = (out != 0).sum().item()
        assert nonzero == 2
        # the two largest must survive
        assert (out.abs() == 2.0).any()
        assert (out.abs() == 1.0).any()

    def test_magnitude_prune_single_element_no_crash(self):
        try:
            import torch
        except ImportError:
            pytest.skip("torch not available")
        from soup_cli.utils.relora import magnitude_prune_tensor

        # 1-element tensor — kthvalue(_, 0) would raise; helper must short-circuit.
        x = torch.tensor([3.14])
        out = magnitude_prune_tensor(x.clone(), prune_ratio=0.5)
        # untouched (use approx for float32 storage)
        assert out.item() == pytest.approx(3.14, abs=1e-5)

    def test_magnitude_prune_keep_all(self):
        try:
            import torch
        except ImportError:
            pytest.skip("torch not available")
        from soup_cli.utils.relora import magnitude_prune_tensor

        x = torch.tensor([1.0, 2.0, 3.0])
        out = magnitude_prune_tensor(x.clone(), prune_ratio=0.001)
        # near-zero prune ratio → at least one element kept
        assert (out != 0).any()

    def test_magnitude_prune_rejects_invalid_ratio(self):
        try:
            import torch
        except ImportError:
            pytest.skip("torch not available")
        from soup_cli.utils.relora import magnitude_prune_tensor
        x = torch.zeros(3)
        with pytest.raises(ValueError):
            magnitude_prune_tensor(x, prune_ratio=0.0)
        with pytest.raises(ValueError):
            magnitude_prune_tensor(x, prune_ratio=1.0)

    def test_magnitude_prune_rejects_non_tensor_input(self):
        try:
            import torch  # noqa: F401
        except ImportError:
            pytest.skip("torch not available")
        from soup_cli.utils.relora import magnitude_prune_tensor
        with pytest.raises(TypeError):
            magnitude_prune_tensor([1.0, 2.0, 3.0], prune_ratio=0.5)
        with pytest.raises(TypeError):
            magnitude_prune_tensor("not a tensor", prune_ratio=0.5)


class TestReLoRACallback:
    def test_callback_disabled_no_op(self):
        from soup_cli.utils.relora import ReLoRACallback
        cb = ReLoRACallback(policy=None)
        # disabled callback never fires
        state = MagicMock(global_step=500, max_steps=1000)
        ctrl = MagicMock()
        args = MagicMock()
        cb.on_step_end(args, state, ctrl)
        assert cb.fire_count == 0

    def test_callback_fires_on_relora_step(self):
        from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy
        cb = ReLoRACallback(policy=ReLoRAPolicy(steps=100))
        # mock model with PEFT-style lora_A / lora_B parameters
        cb._prune_and_reset = MagicMock()  # type: ignore[method-assign]
        state = MagicMock(global_step=100, max_steps=1000)
        ctrl = MagicMock()
        args = MagicMock()
        cb.on_step_end(args, state, ctrl, model=MagicMock(), optimizer=MagicMock())
        assert cb.fire_count == 1
        cb._prune_and_reset.assert_called_once()

    def test_callback_does_not_fire_off_step(self):
        from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy
        cb = ReLoRACallback(policy=ReLoRAPolicy(steps=100))
        cb._prune_and_reset = MagicMock()  # type: ignore[method-assign]
        state = MagicMock(global_step=99, max_steps=1000)
        cb.on_step_end(MagicMock(), state, MagicMock())
        assert cb.fire_count == 0
        cb._prune_and_reset.assert_not_called()

    def test_callback_skips_warmup(self):
        from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy
        cb = ReLoRACallback(policy=ReLoRAPolicy(steps=100, warmup_ratio=0.5))
        cb._prune_and_reset = MagicMock()  # type: ignore[method-assign]
        # at step 100 with total 1000 → warmup is 500 → skip
        state = MagicMock(global_step=100, max_steps=1000)
        cb.on_step_end(MagicMock(), state, MagicMock(), model=MagicMock(), optimizer=MagicMock())
        assert cb.fire_count == 0


class TestReLoRATaskGate:
    def _base_cfg(self, task: str = "sft", backend: str = "transformers") -> dict:
        return {
            "base": "meta-llama/Llama-3.1-8B",
            "task": task,
            "backend": backend,
            "data": {"train": "./data.jsonl"},
            "training": {"relora_steps": 100},
        }

    def test_sft_accepted(self):
        import yaml

        from soup_cli.config.loader import load_config_from_string
        cfg = load_config_from_string(yaml.safe_dump(self._base_cfg("sft")))
        assert cfg.training.relora_steps == 100

    @pytest.mark.parametrize(
        "task",
        ["dpo", "grpo", "kto", "orpo", "simpo", "ipo",
         "ppo", "reward_model", "pretrain", "embedding", "bco"],
    )
    def test_other_tasks_accepted(self, task):
        # v0.40.6 (#67) — multi-trainer expansion lifted the SFT-only gate.
        import yaml

        from soup_cli.config.loader import load_config_from_string
        cfg = load_config_from_string(yaml.safe_dump(self._base_cfg(task)))
        assert cfg.training.relora_steps == 100
        assert cfg.task == task

    def test_mlx_backend_rejected(self):
        import yaml

        from soup_cli.config.loader import load_config_from_string
        with pytest.raises(ValueError, match="mlx"):
            load_config_from_string(yaml.safe_dump(self._base_cfg("sft", "mlx")))

    def test_no_relora_no_gate(self):
        # multi-task without relora_steps stays valid
        import yaml

        from soup_cli.config.loader import load_config_from_string
        d = self._base_cfg("dpo")
        d["training"].pop("relora_steps")
        cfg = load_config_from_string(yaml.safe_dump(d))
        assert cfg.training.relora_steps is None


class TestPruneAndReset:
    def test_prune_and_reset_targets_lora_modules(self):
        try:
            import torch
            import torch.nn as nn
        except ImportError:
            pytest.skip("torch not available")
        from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy

        class FakeLoraModule(nn.Module):
            def __init__(self):
                super().__init__()
                self.lora_A = nn.Linear(4, 2, bias=False)
                self.lora_B = nn.Linear(2, 4, bias=False)
                self.base = nn.Linear(4, 4, bias=False)

            def forward(self, x):
                return self.base(x) + self.lora_B(self.lora_A(x))

        model = FakeLoraModule()
        # set known weight values
        with torch.no_grad():
            model.lora_A.weight.fill_(1.0)
            model.lora_A.weight[0, 0] = 100.0
            model.lora_B.weight.fill_(0.5)
            model.base.weight.fill_(7.0)
        before_base = model.base.weight.clone()

        cb = ReLoRACallback(policy=ReLoRAPolicy(steps=10, prune_ratio=0.9))
        opt = MagicMock()
        opt.state = {}
        cb._prune_and_reset(model, opt)

        # base must be untouched
        assert torch.equal(model.base.weight, before_base)
        # lora_A retains highest-magnitude entry
        assert (model.lora_A.weight.abs() >= 100.0).any()
        # lora_A overall has many zeros now (prune_ratio=0.9)
        zero_frac = (model.lora_A.weight == 0).float().mean().item()
        assert zero_frac > 0.5

    def test_prune_and_reset_clears_real_optimizer_state(self):
        try:
            import torch
            import torch.nn as nn
        except ImportError:
            pytest.skip("torch not available")
        from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy

        class FakeLoraModule(nn.Module):
            def __init__(self):
                super().__init__()
                self.lora_A = nn.Linear(4, 2, bias=False)

            def forward(self, x):
                return self.lora_A(x)

        model = FakeLoraModule()
        opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
        # Run a step so opt.state is populated for the param.
        loss = model(torch.randn(2, 4)).sum()
        loss.backward()
        opt.step()
        param = model.lora_A.weight
        assert param in opt.state
        assert len(opt.state[param]) > 0  # exp_avg, exp_avg_sq, step

        cb = ReLoRACallback(policy=ReLoRAPolicy(steps=10, prune_ratio=0.9))
        cb._prune_and_reset(model, opt)
        # Optimizer state for the pruned param must have been reset to empty
        assert len(opt.state[param]) == 0

    def test_prune_and_reset_respects_reset_optimizer_false(self):
        try:
            import torch
            import torch.nn as nn
        except ImportError:
            pytest.skip("torch not available")
        from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy

        class FakeLoraModule(nn.Module):
            def __init__(self):
                super().__init__()
                self.lora_A = nn.Linear(4, 2, bias=False)

            def forward(self, x):
                return self.lora_A(x)

        model = FakeLoraModule()
        opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
        loss = model(torch.randn(2, 4)).sum()
        loss.backward()
        opt.step()
        param = model.lora_A.weight
        before = len(opt.state[param])

        cb = ReLoRACallback(
            policy=ReLoRAPolicy(steps=10, prune_ratio=0.9, reset_optimizer=False)
        )
        cb._prune_and_reset(model, opt)
        # State preserved when reset_optimizer=False
        assert len(opt.state[param]) == before
