"""Unit tests for reward model implementation."""

import torch
import torch.nn.functional as F
from modules.gpt import GPTConfig, GPTTrainConfig, GPT


def compute_loss_end_indices(Y: torch.Tensor, seq_len: int, semantic_pad_token: int = 4000) -> list[int]:
    """Compute where valid data ends for each sample (copied for testing)."""
    batch_size = Y.shape[0]
    loss_end_index_list = []

    for i in range(batch_size):
        y_sample = Y[i, 0, :]  # First codebook (semantic)

        # Check for BOTH -1 (marked by mask_middle_padding) AND actual pad token
        non_pad_mask = (y_sample != -1) & (y_sample != semantic_pad_token)

        if non_pad_mask.any():
            last_valid = torch.where(non_pad_mask)[0][-1].item()
            end_idx = last_valid + 2  # Y-shift: Y[j]=pad → X[j+1] padding
        else:
            end_idx = seq_len

        loss_end_index_list.append(end_idx)

    return loss_end_index_list


def extract_scalar_rewards(reward_logits, loss_start_index_list, loss_end_index_list=None):
    """Extract scalar rewards by averaging between start and end indices (copied for testing)."""
    batch_size, seq_len = reward_logits.shape
    device = reward_logits.device

    # Position indices
    positions = torch.arange(seq_len, device=device).unsqueeze(0).expand(batch_size, -1)

    # Start indices
    start_indices = (
        torch.tensor(loss_start_index_list, device=device).unsqueeze(1)
        if loss_start_index_list
        else torch.zeros(batch_size, 1, dtype=torch.long, device=device)
    )

    # End indices
    end_indices = (
        torch.tensor(loss_end_index_list, device=device).unsqueeze(1)
        if loss_end_index_list
        else torch.full((batch_size, 1), seq_len, dtype=torch.long, device=device)
    )

    # Mask: start <= position < end
    mask = (positions >= start_indices) & (positions < end_indices)

    # Average
    masked_rewards = reward_logits * mask
    valid_counts = mask.sum(dim=1, keepdim=True).clamp(min=1)
    scalar_rewards = masked_rewards.sum(dim=1) / valid_counts.squeeze(1)

    return scalar_rewards


def token_level_reward_loss(
    reward_logits_chosen,
    reward_logits_rejected,
    loss_start_index_list_chosen,
    loss_start_index_list_rejected,
    loss_end_index_list_chosen,
    loss_end_index_list_rejected,
    beta=1.0,
):
    """Token-level Bradley-Terry loss (copied for testing)."""
    batch_size, seq_len = reward_logits_chosen.shape
    device = reward_logits_chosen.device

    # Create masks inline
    positions = torch.arange(seq_len, device=device).unsqueeze(0).expand(batch_size, -1)

    start_c = torch.tensor(loss_start_index_list_chosen, device=device).unsqueeze(1)
    end_c = torch.tensor(loss_end_index_list_chosen, device=device).unsqueeze(1)
    mask_chosen = (positions >= start_c) & (positions < end_c)

    start_r = torch.tensor(loss_start_index_list_rejected, device=device).unsqueeze(1)
    end_r = torch.tensor(loss_end_index_list_rejected, device=device).unsqueeze(1)
    mask_rejected = (positions >= start_r) & (positions < end_r)

    mask = mask_chosen & mask_rejected

    token_diff = reward_logits_chosen - reward_logits_rejected
    token_losses = -F.logsigmoid(beta * token_diff)

    masked_losses = token_losses * mask
    loss = masked_losses.sum() / mask.sum().clamp(min=1)

    token_correct = (reward_logits_chosen > reward_logits_rejected) & mask
    accuracy = token_correct.sum().float() / mask.sum().float().clamp(min=1)

    return loss, accuracy


def reward_model_loss(reward_chosen, reward_rejected, label_smoothing=0.0):
    """Bradley-Terry loss with label smoothing (copied for testing)."""
    losses = (
        -F.logsigmoid(reward_chosen - reward_rejected) * (1 - label_smoothing)
        - F.logsigmoid(reward_rejected - reward_chosen) * label_smoothing
    )
    loss = losses.mean()
    accuracy = (reward_chosen > reward_rejected).float().mean()
    return loss, accuracy


def test_reward_head_initialization():
    """Test that reward head is properly initialized when use_reward_head=True."""
    config = GPTConfig(
        n_layer=2,
        n_head=4,
        d_head=64,
        block_size=256,
        t_text=128,
        t_audio=128,
        use_reward_head=True,
        output_paradigm="gpt",
        output_distribution="semantic",
    )
    train_config = GPTTrainConfig()
    model = GPT(config, train_config)

    # Check reward head exists
    assert "reward_head" in model.output_modules
    assert hasattr(model.output_modules["reward_head"], "reward_proj")
    assert hasattr(model.output_modules["reward_head"], "ln")

    # Verify reward head is initialized with small weights (std=0.01)
    reward_head = model.output_modules["reward_head"]
    weight_norm = reward_head.reward_proj.weight.norm().item()
    # Expected norm: sqrt(n_embd) * std with std=0.01
    # For small test model, this will be small; for full model ~0.64
    # Just verify it's not too large (< 2.0) and not zero
    assert 0.05 < weight_norm < 2.0, f"Reward head weight norm should be small, got {weight_norm}"

    # Test that it outputs near-zero rewards with small variance
    batch_size = 2
    seq_len = 10
    x = torch.randn(batch_size, seq_len, config.n_embd)
    with torch.no_grad():
        rewards = reward_head(x)
    assert rewards.shape == (batch_size, seq_len, 1)
    reward_mean = rewards.mean().item()
    reward_std = rewards.std().item()
    # Mean should be close to 0, std should be small
    assert abs(reward_mean) < 1.0, f"Reward mean should be near 0, got {reward_mean:.4f}"
    assert reward_std < 2.0, f"Reward std should be small, got {reward_std:.4f}"


def test_reward_head_forward():
    """Test that reward head returns correct shape for sequence output."""
    # Test directly on reward head module (skip full model forward which needs GPU)
    config = GPTConfig(
        n_layer=2,
        n_head=4,
        d_head=64,
        block_size=256,
        t_text=128,
        t_audio=128,
        use_reward_head=True,
        output_paradigm="gpt",
        output_distribution="semantic",
    )
    train_config = GPTTrainConfig()
    model = GPT(config, train_config)
    model.eval()

    # Test reward head on sequence of hidden states
    batch_size = 4
    seq_len = 100
    n_embd = config.n_embd
    hidden_states = torch.randn(batch_size, seq_len, n_embd)

    # Get rewards from reward head (should be one reward per token)
    with torch.no_grad():
        rewards = model.output_modules["reward_head"](hidden_states)

    # Check shape: should be (batch, seq_len, 1)
    assert rewards.shape == (
        batch_size,
        seq_len,
        1,
    ), f"Expected shape ({batch_size}, {seq_len}, 1), got {rewards.shape}"

    # Squeeze and check again
    rewards = rewards.squeeze(-1)
    assert rewards.shape == (
        batch_size,
        seq_len,
    ), f"Expected shape ({batch_size}, {seq_len}), got {rewards.shape}"


def test_bradley_terry_loss():
    """Test Bradley-Terry loss computation."""
    # Create dummy rewards
    reward_chosen = torch.tensor([1.0, 2.0, 3.0, 4.0])
    reward_rejected = torch.tensor([0.5, 1.5, 2.5, 3.5])

    loss, accuracy = reward_model_loss(reward_chosen, reward_rejected)

    # Loss should be positive
    assert loss.item() >= 0

    # Accuracy should be 1.0 (all chosen > rejected)
    assert accuracy.item() == 1.0

    # Test case where some are wrong
    reward_chosen_mixed = torch.tensor([1.0, 0.5, 3.0, 2.0])
    reward_rejected_mixed = torch.tensor([0.5, 1.0, 2.5, 3.0])

    loss_mixed, accuracy_mixed = reward_model_loss(reward_chosen_mixed, reward_rejected_mixed)

    # Accuracy should be 0.5 (2 out of 4 correct)
    assert accuracy_mixed.item() == 0.5


def test_reward_head_gradient_flow():
    """Test that gradients flow through reward head."""
    config = GPTConfig(
        n_layer=2,
        n_head=4,
        d_head=64,
        block_size=256,
        t_text=128,
        t_audio=128,
        use_reward_head=True,
        output_paradigm="gpt",
        output_distribution="semantic",
    )
    train_config = GPTTrainConfig()
    model = GPT(config, train_config)
    model.train()

    # Test gradient flow through reward head directly
    batch_size = 2
    n_embd = config.n_embd
    hidden_states = torch.randn(batch_size, n_embd, requires_grad=True)

    # Forward pass through reward head
    rewards = model.output_modules["reward_head"](hidden_states).squeeze(-1)

    # Compute dummy loss
    loss = rewards.mean()
    loss.backward()

    # Check that reward head has gradients
    assert model.output_modules["reward_head"].reward_proj.weight.grad is not None
    assert model.output_modules["reward_head"].ln.weight.grad is not None
    # Also check input has gradients
    assert hidden_states.grad is not None


def test_freeze_base_model():
    """Test freezing base model while training reward head."""
    config = GPTConfig(
        n_layer=2,
        n_head=4,
        d_head=64,
        block_size=256,
        t_text=128,
        t_audio=128,
        use_reward_head=True,
        output_paradigm="gpt",
        output_distribution="semantic",
    )
    train_config = GPTTrainConfig()
    model = GPT(config, train_config)

    # Freeze everything except reward head
    for name, param in model.named_parameters():
        if "reward_head" not in name:
            param.requires_grad = False

    # Count trainable params
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in model.parameters())

    # Reward head should be much smaller than full model
    assert trainable < total * 0.01, "Reward head should be <1% of total params"

    # Check specific params
    assert model.output_modules["reward_head"].reward_proj.weight.requires_grad
    # Check that transformer layers are frozen (use c_proj which exists in all models)
    assert not model.transformer.h[0].attn.c_proj.weight.requires_grad


def test_paired_preference_data():
    """Test that paired preference data structure is correct."""
    # Simulate paired batch: [neg0, pos0, neg1, pos1, ...]
    batch_size = 8
    rewards = torch.tensor([0.5, 1.5, 0.3, 1.2, 0.7, 1.8, 0.4, 1.3])

    # Split into chosen and rejected
    reward_rejected = rewards[::2]  # Even indices: [0.5, 0.3, 0.7, 0.4]
    reward_chosen = rewards[1::2]  # Odd indices: [1.5, 1.2, 1.8, 1.3]

    assert reward_rejected.shape == (batch_size // 2,)
    assert reward_chosen.shape == (batch_size // 2,)
    assert torch.all(reward_chosen > reward_rejected), "Chosen should be > rejected in test data"


def test_reward_extraction_from_generated_portion():
    """Test that reward is extracted from generated portion only (after loss_start_index)."""
    config = GPTConfig(
        n_layer=2,
        n_head=4,
        d_head=64,
        block_size=256,
        t_text=128,
        t_audio=128,
        use_reward_head=True,
        output_paradigm="gpt",
        output_distribution="semantic",
    )
    train_config = GPTTrainConfig()
    model = GPT(config, train_config)
    model.eval()

    # Test the extraction logic directly without running full model (which needs GPU)
    batch_size = 4
    seq_len = 256
    n_embd = config.n_embd
    x = torch.randn(batch_size, seq_len, n_embd)

    # Simulate different generation start positions
    loss_start_index_list = [50, 100, 75, 120]  # Different for each sample

    # Create dummy reward logits (one per token) from reward head
    reward_logits = torch.randn(batch_size, seq_len)

    # Create Y with padding tokens (-1)
    n_codebooks = config.semantic_n_codebooks + config.coarse_n_codebooks
    Y = torch.randint(0, 1000, (batch_size, n_codebooks, seq_len - 1))
    # Add padding at different positions
    Y[0, :, 180:] = -1  # Sample 0: padding after position 180
    Y[1, :, 200:] = -1  # Sample 1: padding after position 200
    Y[2, :, 150:] = -1  # Sample 2: padding after position 150
    Y[3, :, 220:] = -1  # Sample 3: padding after position 220

    # Extract scalar rewards (specify end indices based on Y padding)
    # For tests with no padding, use None
    scalar_rewards = extract_scalar_rewards(reward_logits, loss_start_index_list, None)

    # Check shape
    assert scalar_rewards.shape == (
        batch_size,
    ), f"Expected shape ({batch_size},), got {scalar_rewards.shape}"

    # Verify each is a scalar
    for i in range(batch_size):
        assert scalar_rewards[i].dim() == 0, f"Sample {i} should be scalar"

    # Verify that different sequences produce different rewards (usually true for random data)
    # This is a basic sanity check that extraction works


def test_extract_scalar_rewards_correctness():
    """Comprehensive test to verify extract_scalar_rewards masking is correct."""
    batch_size = 3
    seq_len = 20
    n_codebooks = 1

    # Create simple reward logits where reward[i, j] = j (position-based for easy verification)
    reward_logits = torch.arange(seq_len, dtype=torch.float32).unsqueeze(0).expand(batch_size, -1)
    # reward_logits[i, j] = j for all samples

    # Create Y with known padding positions
    Y = torch.zeros(batch_size, n_codebooks, seq_len - 1, dtype=torch.long)

    # Sample 0: No padding, start at position 5
    # Should average positions [5, 6, 7, ..., 19]
    # Expected: sum(5..19) / 15 = (5+19)*15/2 / 15 = 12.0
    Y[0, :, :] = 1  # All valid (no -1)

    # Sample 1: Padding starts at Y[14], start at position 8
    # Y[14]=-1 means the target at position 14 is padding
    # Y[j] predicts from X[j] to X[j+1], so Y[14] corresponds to predicting X[15]
    # If Y[14]=-1, we can't use X[15] (it's in padding region)
    # Valid X positions: 0-14 (Y[0..13] are valid)
    # Should average positions [8, 9, 10, ..., 14]
    # Expected: sum(8..14) / 7 = 11.0
    Y[1, :, :14] = 1  # Valid up to Y[13]
    Y[1, :, 14:] = -1  # Y[14]=-1 → X[15] onwards is padding

    # Sample 2: Start at 10, padding at Y[11]
    # Y[11]=-1 excludes X[12] onwards
    # Valid X positions: 0-11 (Y[0..10] are valid)
    # Should average positions [10, 11]
    # Expected: sum(10..11) / 2 = 10.5
    Y[2, :, :11] = 1
    Y[2, :, 11:] = -1

    loss_start_index_list = [5, 8, 10]

    # Compute end indices from Y padding
    # Sample 0: No padding → end at 20
    # Sample 1: Y[14]=-1 → end at 15 (14+2 from Y-shift, but actually 15 is where it ends)
    # Sample 2: Y[11]=-1 → end at 12
    loss_end_index_list = [20, 15, 12]

    # Extract scalar rewards
    scalar_rewards = extract_scalar_rewards(reward_logits, loss_start_index_list, loss_end_index_list)

    # Verify shape
    assert scalar_rewards.shape == (batch_size,), f"Shape mismatch: {scalar_rewards.shape}"

    # Verify each reward is correct
    # Sample 0: mean of positions 5-19 (15 values)
    expected_0 = sum(range(5, 20)) / 15.0
    actual_0 = scalar_rewards[0].item()
    assert abs(actual_0 - expected_0) < 0.01, f"Sample 0: expected {expected_0:.2f}, got {actual_0:.2f}"

    # Sample 1: mean of positions 8-14 (7 values, Y[14]=-1 excludes X[15])
    expected_1 = sum(range(8, 15)) / 7.0
    actual_1 = scalar_rewards[1].item()
    assert abs(actual_1 - expected_1) < 0.01, f"Sample 1: expected {expected_1:.2f}, got {actual_1:.2f}"

    # Sample 2: mean of positions 10-11 (2 values, Y[11]=-1 excludes X[12])
    expected_2 = sum(range(10, 12)) / 2.0
    actual_2 = scalar_rewards[2].item()
    assert abs(actual_2 - expected_2) < 0.01, f"Sample 2: expected {expected_2:.2f}, got {actual_2:.2f}"

    print(f"  Sample 0: {actual_0:.2f} == {expected_0:.2f} ✓")
    print(f"  Sample 1: {actual_1:.2f} == {expected_1:.2f} ✓")
    print(f"  Sample 2: {actual_2:.2f} == {expected_2:.2f} ✓")


def test_extract_scalar_rewards_edge_cases():
    """Test edge cases for extract_scalar_rewards."""
    batch_size = 4
    seq_len = 100
    n_codebooks = 1

    reward_logits = torch.randn(batch_size, seq_len)
    Y = torch.randint(0, 1000, (batch_size, n_codebooks, seq_len - 1))

    # Edge case 1: loss_start_index at 0 (full sequence)
    loss_start_index_list = [0, 0, 0, 0]
    Y[:, :, :] = 1  # No padding

    rewards = extract_scalar_rewards(reward_logits, loss_start_index_list, None)
    assert rewards.shape == (batch_size,)

    # Each should be mean of full sequence
    for i in range(batch_size):
        expected = reward_logits[i].mean()
        assert abs(rewards[i].item() - expected.item()) < 1e-5, f"Full sequence mean mismatch"

    # Edge case 2: loss_start_index very late (only last few tokens)
    loss_start_index_list = [95, 96, 97, 98]
    Y[:, :, :] = 1  # No padding

    rewards = extract_scalar_rewards(reward_logits, loss_start_index_list, None)
    assert rewards.shape == (batch_size,)

    # Each should be mean of last few tokens
    for i in range(batch_size):
        start = loss_start_index_list[i]
        expected = reward_logits[i, start:].mean()
        assert abs(rewards[i].item() - expected.item()) < 1e-5, f"Late start mean mismatch"

    # Edge case 3: Heavy padding (only first few tokens valid)
    Y[:, :, 5:] = -1  # Heavy padding - Y[5]=-1 means X[6] onwards is padding
    loss_start_index_list = [0, 0, 0, 0]
    # Y[5]=-1 → last_valid=4 → end_idx=4+2=6
    loss_end_index_list = [6, 6, 6, 6]

    rewards = extract_scalar_rewards(reward_logits, loss_start_index_list, loss_end_index_list)
    assert rewards.shape == (batch_size,)
    # Should average over only positions 0-5 (Y[5]=-1 → X[6] is padding, so valid up to X[5])
    for i in range(batch_size):
        expected = reward_logits[i, :6].mean()  # Positions 0-5
        assert abs(rewards[i].item() - expected.item()) < 1e-5, f"Heavy padding mean mismatch"

    # Edge case 4: None for loss_start_index_list (should default to 0)
    Y[:, :, :] = 1  # No padding
    rewards = extract_scalar_rewards(reward_logits, None, None)
    assert rewards.shape == (batch_size,)
    for i in range(batch_size):
        expected = reward_logits[i].mean()
        assert abs(rewards[i].item() - expected.item()) < 1e-5


def test_token_level_reward_loss():
    """Test token-level Bradley-Terry loss computation."""
    batch_size = 2
    seq_len = 10
    n_codebooks = 1

    # Create token-level rewards where chosen is consistently better
    # Chosen: [0.5, 0.6, 0.7, ..., 1.4]
    # Rejected: [0.4, 0.5, 0.6, ..., 1.3]
    reward_logits_chosen = (
        torch.arange(5, 15, dtype=torch.float32).unsqueeze(0).expand(batch_size, -1) / 10.0
    )
    reward_logits_rejected = (
        torch.arange(4, 14, dtype=torch.float32).unsqueeze(0).expand(batch_size, -1) / 10.0
    )

    # Create Y with no padding
    Y_chosen = torch.ones(batch_size, n_codebooks, seq_len - 1, dtype=torch.long)
    Y_rejected = torch.ones(batch_size, n_codebooks, seq_len - 1, dtype=torch.long)

    # Start from position 2
    loss_start_index_list_chosen = [2, 2]
    loss_start_index_list_rejected = [2, 2]

    # Compute token-level loss
    # Compute end indices (no padding, so use seq_len)
    seq_len = reward_logits_chosen.shape[1]
    loss_end_list = [seq_len, seq_len]

    loss, accuracy = token_level_reward_loss(
        reward_logits_chosen,
        reward_logits_rejected,
        loss_start_index_list_chosen,
        loss_start_index_list_rejected,
        loss_end_list,  # chosen end
        loss_end_list,  # rejected end
        beta=1.0,
    )

    # Loss should be positive
    assert loss.item() > 0

    # Accuracy should be 1.0 (all tokens have chosen > rejected)
    assert accuracy.item() == 1.0, f"Expected accuracy 1.0, got {accuracy.item()}"

    # Test with some positions wrong
    reward_logits_mixed_chosen = reward_logits_chosen.clone()
    reward_logits_mixed_chosen[:, 5] = 0.3  # Make position 5 worse than rejected

    loss_mixed, acc_mixed = token_level_reward_loss(
        reward_logits_mixed_chosen,
        reward_logits_rejected,
        loss_start_index_list_chosen,
        loss_start_index_list_rejected,
        loss_end_list,  # chosen end
        loss_end_list,  # rejected end
        beta=1.0,
    )

    # Accuracy should be less than 1.0 now
    # Positions 2-9 = 8 tokens, position 5 is wrong, so 7/8 = 0.875
    assert 0.8 < acc_mixed.item() < 0.9, f"Expected accuracy ~0.875, got {acc_mixed.item()}"


def test_token_level_with_padding():
    """Test token-level loss correctly handles padding."""
    batch_size = 2
    seq_len = 20
    n_codebooks = 1

    # Position-based rewards for easy verification
    reward_logits_chosen = torch.arange(seq_len, dtype=torch.float32).unsqueeze(0).expand(batch_size, -1)
    reward_logits_rejected = (
        (torch.arange(seq_len, dtype=torch.float32) - 0.5).unsqueeze(0).expand(batch_size, -1)
    )

    # Create Y with padding
    Y_chosen = torch.ones(batch_size, n_codebooks, seq_len - 1, dtype=torch.long)
    Y_rejected = torch.ones(batch_size, n_codebooks, seq_len - 1, dtype=torch.long)

    # Add padding at position 10 for both
    Y_chosen[:, :, 10:] = -1
    Y_rejected[:, :, 10:] = -1

    # Start from position 5
    loss_start_list = [5, 5]

    # Compute end indices from Y (padding at position 10)
    seq_len = reward_logits_chosen.shape[1]
    # Y[10]=-1 means X[11] padding, so end at 12 (exclusive)
    loss_end_list = [12, 12]

    loss, accuracy = token_level_reward_loss(
        reward_logits_chosen,
        reward_logits_rejected,
        loss_start_list,
        loss_start_list,
        loss_end_list,
        loss_end_list,
        beta=1.0,
    )

    # Should only compute loss on positions 5-11 (7 positions total)
    # Y[10]=-1 means X[11] is last valid, but since Y padding starts at 10,
    # valid positions are 5-10 (6 positions)
    # All should have chosen > rejected
    assert accuracy.item() == 1.0
    assert loss.item() > 0


def test_label_smoothing_loss():
    """Test Bradley-Terry loss with label smoothing."""
    reward_chosen = torch.tensor([1.0, 2.0, 3.0, 4.0])
    reward_rejected = torch.tensor([0.5, 1.5, 2.5, 3.5])

    # Loss without smoothing
    loss_no_smooth, acc_no_smooth = reward_model_loss(
        reward_chosen, reward_rejected, label_smoothing=0.0
    )

    # Loss with smoothing
    loss_smooth, acc_smooth = reward_model_loss(reward_chosen, reward_rejected, label_smoothing=0.1)

    # Smoothed loss should be higher (less confident)
    assert loss_smooth.item() > loss_no_smooth.item(), "Smoothed loss should be higher"

    # Accuracy should be the same (smoothing doesn't change predictions)
    assert acc_smooth.item() == acc_no_smooth.item() == 1.0


def test_compute_loss_end_indices():
    """Test compute_loss_end_indices with various padding scenarios."""
    semantic_pad_token = 4000
    seq_len = 100
    batch_size = 6
    n_codebooks = 1

    # Create Y with shape (batch, n_codebooks, seq_len-1)
    Y = torch.zeros(batch_size, n_codebooks, seq_len - 1, dtype=torch.long)

    # Case 1: No padding - full sequence
    Y[0, :, :] = 1  # All valid data

    # Case 2: Padding with 1 token at end (only marked with semantic_pad_token=4000)
    # Last valid data at Y[97], then Y[98]=4000 (1 pad token)
    # This simulates mask_middle_padding NOT marking it as -1 (only 1 pad token)
    Y[1, :, :98] = 1
    Y[1, :, 98:] = semantic_pad_token

    # Case 3: Padding with 2 tokens at end (only marked with semantic_pad_token=4000)
    # Last valid data at Y[96], then Y[97:99]=4000 (2 pad tokens)
    Y[2, :, :97] = 1
    Y[2, :, 97:] = semantic_pad_token

    # Case 4: Padding with 3+ tokens at end (marked with -1 by mask_middle_padding)
    # Last valid data at Y[94], then Y[95:99]=4000, but middle ones marked as -1
    Y[3, :, :95] = 1
    Y[3, :, 95] = semantic_pad_token  # First pad
    Y[3, :, 96:98] = -1  # Middle pads marked as -1
    Y[3, :, 98] = semantic_pad_token  # Last pad

    # Case 5: Different pair - short sequence (like chosen vs rejected)
    # Last valid at Y[49], rest is padding
    Y[4, :, :50] = 1
    Y[4, :, 50:] = semantic_pad_token

    # Case 6: Very short sequence with only -1 padding
    Y[5, :, :20] = 1
    Y[5, :, 20:] = -1

    # Compute end indices
    end_indices = compute_loss_end_indices(Y, seq_len, semantic_pad_token)

    # Verify results
    # Case 1: No padding → end at seq_len
    assert end_indices[0] == seq_len, f"Case 1: Expected {seq_len}, got {end_indices[0]}"

    # Case 2: Last valid Y[97] → end_idx = 97 + 2 = 99
    # Y[98]=4000 means X[99] is padding
    assert end_indices[1] == 99, f"Case 2 (1 pad): Expected 99, got {end_indices[1]}"

    # Case 3: Last valid Y[96] → end_idx = 96 + 2 = 98
    # Y[97]=4000 means X[98] is padding
    assert end_indices[2] == 98, f"Case 3 (2 pads): Expected 98, got {end_indices[2]}"

    # Case 4: Last valid Y[94] → end_idx = 94 + 2 = 96
    # Y[95]=4000 or -1 means X[96] is padding
    assert end_indices[3] == 96, f"Case 4 (3+ pads with -1): Expected 96, got {end_indices[3]}"

    # Case 5: Last valid Y[49] → end_idx = 49 + 2 = 51
    assert end_indices[4] == 51, f"Case 5 (short seq): Expected 51, got {end_indices[4]}"

    # Case 6: Last valid Y[19] → end_idx = 19 + 2 = 21
    assert end_indices[5] == 21, f"Case 6 (only -1): Expected 21, got {end_indices[5]}"

    print(f"  All cases validated:")
    print(f"    No padding: {end_indices[0]}")
    print(f"    1 pad token: {end_indices[1]}")
    print(f"    2 pad tokens: {end_indices[2]}")
    print(f"    3+ pads (-1): {end_indices[3]}")
    print(f"    Short sequence: {end_indices[4]}")
    print(f"    Only -1 padding: {end_indices[5]}")


def test_loss_indices_for_preference_pairs():
    """Test that chosen/rejected pairs can have different loss indices."""
    semantic_pad_token = 4000
    seq_len = 100
    batch_size = 4  # 2 pairs
    n_codebooks = 1

    # Create Y for 2 preference pairs (interleaved: [rej_0, cho_0, rej_1, cho_1])
    Y = torch.zeros(batch_size, n_codebooks, seq_len - 1, dtype=torch.long)
    loss_start_index_list = [10, 10, 20, 20]  # Same start for each pair

    # Pair 0: Rejected (short) vs Chosen (long)
    Y[0, :, :60] = 1  # Rejected: valid to Y[59]
    Y[0, :, 60:] = semantic_pad_token

    Y[1, :, :80] = 1  # Chosen: valid to Y[79]
    Y[1, :, 80:] = semantic_pad_token

    # Pair 1: Rejected (long) vs Chosen (short)
    Y[2, :, :90] = 1  # Rejected: valid to Y[89]
    Y[2, :, 90:] = -1

    Y[3, :, :50] = 1  # Chosen: valid to Y[49]
    Y[3, :, 50:] = semantic_pad_token

    # Compute end indices
    end_indices = compute_loss_end_indices(Y, seq_len, semantic_pad_token)

    # Verify pairs have DIFFERENT lengths
    # Pair 0: Rejected valid to Y[59] → end = 59+2 = 61
    rej_0_end = end_indices[0]
    cho_0_end = end_indices[1]  # Chosen valid to Y[79] → end = 79+2 = 81
    assert rej_0_end == 61, f"Pair 0 rejected: Expected 61, got {rej_0_end}"
    assert cho_0_end == 81, f"Pair 0 chosen: Expected 81, got {cho_0_end}"
    assert rej_0_end != cho_0_end, "Pair 0: rejected and chosen should have DIFFERENT end indices!"

    # Pair 1: Rejected valid to Y[89] → end = 89+2 = 91
    rej_1_end = end_indices[2]
    cho_1_end = end_indices[3]  # Chosen valid to Y[49] → end = 49+2 = 51
    assert rej_1_end == 91, f"Pair 1 rejected: Expected 91, got {rej_1_end}"
    assert cho_1_end == 51, f"Pair 1 chosen: Expected 51, got {cho_1_end}"
    assert rej_1_end != cho_1_end, "Pair 1: rejected and chosen should have DIFFERENT end indices!"

    # Verify they use different amounts of data
    rej_0_len = rej_0_end - loss_start_index_list[0]
    cho_0_len = cho_0_end - loss_start_index_list[1]
    rej_1_len = rej_1_end - loss_start_index_list[2]
    cho_1_len = cho_1_end - loss_start_index_list[3]

    print(f"  Pair 0: rejected_len={rej_0_len}, chosen_len={cho_0_len} ✓ DIFFERENT")
    print(f"  Pair 1: rejected_len={rej_1_len}, chosen_len={cho_1_len} ✓ DIFFERENT")


if __name__ == "__main__":
    # Run tests
    print("Testing reward head initialization...")
    test_reward_head_initialization()
    print("✓ Passed")

    print("Testing reward head forward pass...")
    test_reward_head_forward()
    print("✓ Passed")

    print("Testing Bradley-Terry loss...")
    test_bradley_terry_loss()
    print("✓ Passed")

    print("Testing gradient flow...")
    test_reward_head_gradient_flow()
    print("✓ Passed")

    print("Testing freeze base model...")
    test_freeze_base_model()
    print("✓ Passed")

    print("Testing paired preference data...")
    test_paired_preference_data()
    print("✓ Passed")

    print("Testing reward extraction from generated portion...")
    test_reward_extraction_from_generated_portion()
    print("✓ Passed")

    print("Testing extract_scalar_rewards correctness...")
    test_extract_scalar_rewards_correctness()
    print("✓ Passed")

    print("Testing extract_scalar_rewards edge cases...")
    test_extract_scalar_rewards_edge_cases()
    print("✓ Passed")

    print("Testing label smoothing loss...")
    test_label_smoothing_loss()
    print("✓ Passed")

    print("Testing token-level reward loss...")
    test_token_level_reward_loss()
    print("✓ Passed")

    print("Testing token-level loss with padding...")
    test_token_level_with_padding()
    print("✓ Passed")

    print("Testing compute_loss_end_indices...")
    test_compute_loss_end_indices()
    print("✓ Passed")

    print("Testing loss indices for preference pairs...")
    test_loss_indices_for_preference_pairs()
    print("✓ Passed")

    print("\n✅ All tests passed!")
