"""
Unit tests for AdaMSS ASA (Adaptive Subspace Allocation) functionality.

Tests cover:
- update_importance: EMA-based importance score accumulation
- reset_importance: clearing accumulated importance scores
- update_and_allocate: full ASA flow (accumulate → global mask → reset)
"""

import torch
from torch import nn

from peft import AdamssConfig, get_peft_model
from peft.tuners.adamss.layer import AdamssLayer


class SimpleMLP(nn.Module):
    """Minimal MLP for testing."""

    def __init__(self, in_features=20, hidden=40, out_features=5):
        super().__init__()
        self.lin0 = nn.Linear(in_features, hidden)
        self.relu = nn.ReLU()
        self.lin1 = nn.Linear(hidden, out_features)

    def forward(self, x):
        return self.lin1(self.relu(self.lin0(x)))


def _make_asa_model(target_modules=("lin0", "lin1"), r=8, num_subspaces=4, subspace_rank=1, **extra):
    """Create a simple model with ASA enabled."""
    base = SimpleMLP()
    # Defaults that can be overridden via **extra
    config_kwargs = {
        "target_modules": list(target_modules),
        "r": r,
        "num_subspaces": num_subspaces,
        "subspace_rank": subspace_rank,
        "init_weights": None,
        "use_asa": True,
        "asa_target_subspaces": 2,
        "init_warmup": 0,
        "final_warmup": 100,
        "mask_interval": 10,
    }
    config_kwargs.update(extra)
    config = AdamssConfig(**config_kwargs)
    return get_peft_model(base, config)


def _run_train_step(model, optimizer, in_features=20):
    """Run one full training step (forward + backward + optimizer)."""
    x = torch.randn(4, in_features)
    out = model(x)
    loss = out.sum()
    loss.backward()
    optimizer.step()
    optimizer.zero_grad()
    return loss


def _get_adamss_layers(model):
    """Collect all AdamssLayer modules in the model."""
    return [m for m in model.modules() if isinstance(m, AdamssLayer)]


class TestAdamssAsa:
    # -- update_importance --------------------------------------------------

    def test_importance_populated_after_update(self):
        """update_importance should populate exp_avg_ipt_A/B and exp_avg_unc_A/B."""
        model = _make_asa_model()

        # Forward+backward (no optimizer step needed, we just need gradients)
        x = torch.randn(4, 20)
        model(x).sum().backward()

        layers = _get_adamss_layers(model)
        assert len(layers) > 0
        layer = layers[0]
        adapter = "default"

        # Before update: importance lists should be all None
        assert all(v is None for v in layer.exp_avg_ipt_A[adapter])
        assert all(v is None for v in layer.exp_avg_unc_A[adapter])

        # Update importance
        layer.update_importance(adapter, importance_beta=0.85, uncertainty_beta=0.85)

        # After update: at least some entries should be populated
        assert any(v is not None for v in layer.exp_avg_ipt_A[adapter]), "exp_avg_ipt_A should have entries"
        assert any(v is not None for v in layer.exp_avg_unc_A[adapter]), "exp_avg_unc_A should have entries"

        # At least some scores should be non-zero (B was seeded)
        has_nonzero = any(v.abs().sum() > 0 for v in layer.exp_avg_ipt_A[adapter] if v is not None)
        assert has_nonzero, "At least some importance scores should be non-zero"

    def test_importance_accumulates_across_steps(self):
        """Multiple training steps should produce changing (EMA-accumulated) scores."""
        model = _make_asa_model()
        optimizer = torch.optim.AdamW(model.parameters(), lr=0.1)

        layers = _get_adamss_layers(model)
        layer = layers[0]
        adapter = "default"

        # Step 1: train so B becomes non-zero
        x = torch.randn(4, 20)
        model(x).sum().backward()
        optimizer.step()
        optimizer.zero_grad()

        # Step 2: now gradients for A should be non-zero
        model(x).sum().backward()
        layer.update_importance(adapter, 0.85, 0.85)
        optimizer.step()
        optimizer.zero_grad()

        # Find first populated entry
        first_idx = next(i for i, v in enumerate(layer.exp_avg_ipt_A[adapter]) if v is not None)
        score_after_2 = layer.exp_avg_ipt_A[adapter][first_idx].clone()

        # Step 3: another update should change scores via EMA
        model(x).sum().backward()
        layer.update_importance(adapter, 0.85, 0.85)
        optimizer.step()
        optimizer.zero_grad()

        score_after_3 = layer.exp_avg_ipt_A[adapter][first_idx].clone()

        assert not torch.allclose(score_after_2, score_after_3), (
            "Importance should change between steps due to EMA accumulation"
        )

    # -- reset_importance ---------------------------------------------------

    def test_reset_clears_scores(self):
        """reset_importance should clear all accumulated scores."""
        model = _make_asa_model()

        x = torch.randn(4, 20)
        model(x).sum().backward()

        layers = _get_adamss_layers(model)
        layer = layers[0]
        adapter = "default"

        # Populate importance
        layer.update_importance(adapter, 0.85, 0.85)
        assert any(v is not None for v in layer.exp_avg_ipt_A[adapter])

        # Reset
        layer.reset_importance(adapter)

        # After reset: all entries should be None
        assert all(v is None for v in layer.exp_avg_ipt_A[adapter]), "exp_avg_ipt_A should be all None after reset"
        assert all(v is None for v in layer.exp_avg_unc_A[adapter]), "exp_avg_unc_A should be all None after reset"

    # -- update_and_allocate ------------------------------------------------

    def test_importance_accumulated_every_step(self):
        """update_and_allocate should accumulate importance on non-mask-interval steps."""
        model = _make_asa_model(init_warmup=0, final_warmup=100, mask_interval=10)
        optimizer = torch.optim.AdamW(model.parameters(), lr=0.1)

        # Step 0: train to make B non-zero
        _run_train_step(model, optimizer)

        # Steps 1-2: in warmup, NOT a mask interval → should accumulate importance
        x = torch.randn(4, 20)
        model(x).sum().backward()
        optimizer.step()
        model.base_model.update_and_allocate(1)
        optimizer.zero_grad()

        layers = _get_adamss_layers(model)
        layer = layers[0]
        assert any(v is not None for v in layer.exp_avg_ipt_A["default"]), (
            "Importance should be populated after step 1 (non-mask-interval)"
        )

    def test_masking_reduces_active_params(self):
        """At mask intervals, some subspaces should be frozen."""
        model = _make_asa_model(
            init_warmup=1,
            final_warmup=100,
            mask_interval=5,
            asa_target_subspaces=2,
            num_subspaces=4,
        )
        optimizer = torch.optim.AdamW(model.parameters(), lr=0.1)

        # Count initially active params
        layers = _get_adamss_layers(model)
        initial_active = sum(1 for layer in layers for p in layer.adamss_A["default"] if p.requires_grad)

        # Train for several steps.  Step 0 warms up B (B=0 initially).
        # Steps 1-5 accumulate importance.  Step 5 hits mask_interval (5%5==0)
        # and triggers masking with meaningful scores.
        for step in range(6):
            x = torch.randn(4, 20)
            model(x).sum().backward()
            optimizer.step()
            model.base_model.update_and_allocate(step)
            optimizer.zero_grad()

        # After masking: should have fewer active params
        final_active = sum(1 for layer in layers for p in layer.adamss_A["default"] if p.requires_grad)
        final_frozen = sum(1 for layer in layers for p in layer.adamss_A["default"] if not p.requires_grad)

        assert final_frozen > 0, "Expected some subspace parameters to be frozen by ASA"
        assert final_active < initial_active, f"Active params should decrease: {initial_active} → {final_active}"

    def test_importance_reset_after_masking(self):
        """After a mask interval, importance should be reset for fresh accumulation."""
        model = _make_asa_model(init_warmup=1, final_warmup=100, mask_interval=5)
        optimizer = torch.optim.AdamW(model.parameters(), lr=0.1)

        # Run to step 5 which triggers masking (5 % 5 == 0) and then reset
        for step in range(6):
            x = torch.randn(4, 20)
            model(x).sum().backward()
            optimizer.step()
            model.base_model.update_and_allocate(step)
            optimizer.zero_grad()

        # After mask interval at step 5: importance should be cleared
        layers = _get_adamss_layers(model)
        for layer in layers:
            assert all(v is None for v in layer.exp_avg_ipt_A["default"]), (
                "Importance should be reset after mask interval"
            )

    def test_no_masking_outside_warmup(self):
        """update_and_allocate should be a no-op outside warmup range."""
        model = _make_asa_model(init_warmup=50, final_warmup=100, mask_interval=10)
        optimizer = torch.optim.AdamW(model.parameters(), lr=0.1)

        # Run step 10 (mask_interval hit but BEFORE init_warmup=50)
        _run_train_step(model, optimizer)
        model.base_model.update_and_allocate(10)

        # No importance should be accumulated (outside warmup)
        layers = _get_adamss_layers(model)
        for layer in layers:
            assert all(v is None for v in layer.exp_avg_ipt_A["default"]), (
                "No importance accumulation should happen outside warmup"
            )

    def test_asa_disabled_is_noop(self):
        """update_and_allocate should be a no-op when use_asa=False."""
        base = SimpleMLP()
        config = AdamssConfig(
            target_modules=["lin0"],
            r=8,
            num_subspaces=4,
            subspace_rank=1,
            use_asa=False,
        )
        model = get_peft_model(base, config)
        optimizer = torch.optim.AdamW(model.parameters(), lr=0.1)

        _run_train_step(model, optimizer)
        # Should not raise
        model.base_model.update_and_allocate(0)
        model.base_model.update_and_allocate(100)

        # All params still trainable
        layers = _get_adamss_layers(model)
        for layer in layers:
            for p in layer.adamss_A["default"]:
                assert p.requires_grad
