# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------

import logging
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest
import torch
import torch.nn as nn

import rfdetr.models.backbone.dinov2 as dinov2_module
from rfdetr._namespace import _namespace_from_configs
from rfdetr.config import RFDETRNanoConfig, TrainConfig
from rfdetr.models import build_model
from rfdetr.models.backbone.dinov2 import DinoV2
from rfdetr.models.backbone.dinov2_with_windowed_attn import (
    Dinov2WithRegistersDropPath,
    WindowedDinov2WithRegistersBackbone,
)
from rfdetr.models.lwdetr import LWDETR


@pytest.fixture
def model_with_drop_path(monkeypatch: pytest.MonkeyPatch) -> LWDETR:
    """Create RF-DETR Nano LWDETR with drop_path enabled."""
    monkeypatch.setattr(
        WindowedDinov2WithRegistersBackbone,
        "from_pretrained",
        classmethod(lambda cls, name, config: cls(config)),
    )
    mc = RFDETRNanoConfig(num_classes=3, pretrain_weights=None)
    tc = TrainConfig(
        dataset_dir=".",
        output_dir=".",
        drop_path=0.1,
    )
    args = _namespace_from_configs(mc, tc)
    return build_model(args)


@pytest.fixture
def model_without_drop_path(monkeypatch: pytest.MonkeyPatch) -> LWDETR:
    """Create RF-DETR Nano LWDETR without drop_path."""
    monkeypatch.setattr(
        WindowedDinov2WithRegistersBackbone,
        "from_pretrained",
        classmethod(lambda cls, name, config: cls(config)),
    )
    mc = RFDETRNanoConfig(num_classes=3, pretrain_weights=None)
    tc = TrainConfig(
        dataset_dir=".",
        output_dir=".",
        drop_path=0.0,
    )
    args = _namespace_from_configs(mc, tc)
    return build_model(args)


def test_get_backbone_encoder_layers_dinov2(model_with_drop_path: LWDETR) -> None:
    """Verify _get_backbone_encoder_layers() returns encoder.encoder.layer for DinoV2."""
    model = model_with_drop_path

    layers = model._get_backbone_encoder_layers()
    assert layers is not None

    enc = model.backbone[0].encoder
    assert hasattr(enc, "encoder"), "DinoV2 encoder should have encoder attribute"
    assert hasattr(enc.encoder, "encoder"), "DinoV2 encoder.encoder should have encoder attribute"
    assert hasattr(enc.encoder.encoder, "layer"), "DinoV2 encoder.encoder.encoder should have layer attribute"
    assert layers is enc.encoder.encoder.layer, "Should return encoder.encoder.encoder.layer"

    assert len(layers) > 0, "Should have at least one layer"
    for layer in layers:
        assert hasattr(layer, "drop_path"), "Each layer should have drop_path attribute"


def test_update_drop_path_dinov2(model_with_drop_path: LWDETR) -> None:
    """Verify update_drop_path() sets drop_prob values correctly with linear schedule."""
    model = model_with_drop_path

    layers = model._get_backbone_encoder_layers()
    assert layers is not None

    num_layers = len(layers)
    drop_path_rate = 0.1

    model.update_drop_path(drop_path_rate, num_layers)

    # All layers must be Dinov2WithRegistersDropPath (drop_path_rate=0.1 > 0 at model build time).
    expected_rates = [x.item() for x in torch.linspace(0, drop_path_rate, num_layers)]
    for i, layer in enumerate(layers):
        assert isinstance(layer.drop_path, Dinov2WithRegistersDropPath), (
            f"Layer {i} drop_path should be Dinov2WithRegistersDropPath, got {type(layer.drop_path)}"
        )
        actual_prob = layer.drop_path.drop_prob
        assert abs(actual_prob - expected_rates[i]) < 1e-6, (
            f"Layer {i} drop_prob should be {expected_rates[i]}, got {actual_prob}"
        )

    assert abs(layers[0].drop_path.drop_prob - 0.0) < 1e-6, "First layer should have drop_prob = 0"
    assert abs(layers[-1].drop_path.drop_prob - drop_path_rate) < 1e-6, (
        f"Last layer should have drop_prob = {drop_path_rate}"
    )


def test_drop_path_initialization(model_with_drop_path: LWDETR, model_without_drop_path: LWDETR) -> None:
    """Verify drop_path initialization: Dinov2WithRegistersDropPath vs Identity based on rate."""
    layers_with_dp = model_with_drop_path._get_backbone_encoder_layers()
    layers_without_dp = model_without_drop_path._get_backbone_encoder_layers()

    assert layers_with_dp is not None
    assert layers_without_dp is not None

    # drop_path_rate=0.1 -> every layer initialised as Dinov2WithRegistersDropPath
    for i, layer in enumerate(layers_with_dp):
        assert hasattr(layer, "drop_path"), "Layer should have drop_path attribute"
        assert isinstance(layer.drop_path, Dinov2WithRegistersDropPath), (
            f"Layer {i}: expected Dinov2WithRegistersDropPath, got {type(layer.drop_path)}"
        )

    # drop_path_rate=0.0 -> every layer initialised as nn.Identity
    for i, layer in enumerate(layers_without_dp):
        assert hasattr(layer, "drop_path"), "Layer should have drop_path attribute"
        assert isinstance(layer.drop_path, torch.nn.Identity), (
            f"Layer {i}: expected nn.Identity for zero drop_path, got {type(layer.drop_path)}"
        )


def test_update_drop_path_handles_missing_layers(model_with_drop_path: LWDETR, monkeypatch: pytest.MonkeyPatch) -> None:
    """Verify update_drop_path() handles models without recognizable layer structure gracefully."""
    model = model_with_drop_path

    monkeypatch.setattr(model, "_get_backbone_encoder_layers", lambda: None)

    # Should not raise an error, just return early
    model.update_drop_path(0.1, 12)


def test_update_drop_path_partial_layers(model_with_drop_path: LWDETR) -> None:
    """Verify min() guard prevents IndexError when vit_encoder_num_layers > len(layers)."""
    model = model_with_drop_path

    layers = model._get_backbone_encoder_layers()
    assert layers is not None
    actual_num_layers = len(layers)

    # Request more layers than exist in the backbone
    requested_num_layers = actual_num_layers + 4
    drop_path_rate = 0.2

    # Should not raise IndexError
    model.update_drop_path(drop_path_rate, requested_num_layers)

    # Each updated layer gets a rate from 0 to drop_path_rate (shorter, capped linspace)
    expected_rates = [x.item() for x in torch.linspace(0, drop_path_rate, actual_num_layers)]
    for i in range(actual_num_layers):
        actual_prob = layers[i].drop_path.drop_prob
        assert abs(actual_prob - expected_rates[i]) < 1e-6, (
            f"Layer {i} drop_prob should be {expected_rates[i]}, got {actual_prob}"
        )


def test_non_windowed_drop_path_warns(monkeypatch: pytest.MonkeyPatch) -> None:
    """Verify a warning is emitted when drop_path_rate > 0 with non-windowed backbone."""
    mock_backbone = MagicMock()
    monkeypatch.setattr(dinov2_module, "AutoBackbone", MagicMock(from_pretrained=MagicMock(return_value=mock_backbone)))

    # The rf-detr logger sets propagate=False, so intercept warning() directly.
    warning_messages: list[str] = []
    rf_detr_logger = logging.getLogger("rf-detr")
    monkeypatch.setattr(rf_detr_logger, "warning", lambda msg, *args, **kwargs: warning_messages.append(msg))

    DinoV2(size="base", use_windowed_attn=False, drop_path_rate=0.1)

    assert any("drop_path_rate" in msg and "ignored" in msg for msg in warning_messages), (
        "Expected warning about drop_path_rate being ignored for non-windowed backbone"
    )


def test_get_backbone_encoder_layers_blocks_path() -> None:
    """Verify _get_backbone_encoder_layers() returns enc.blocks for standard ViT backbones."""
    mock_blocks = nn.ModuleList([nn.Linear(1, 1) for _ in range(3)])
    # SimpleNamespace gives only the attributes we define, so hasattr checks work correctly.
    mock_encoder = SimpleNamespace(blocks=mock_blocks)
    mock_self = SimpleNamespace(backbone=[SimpleNamespace(encoder=mock_encoder)])

    result = LWDETR._get_backbone_encoder_layers(mock_self)  # type: ignore[arg-type]
    assert result is mock_blocks, "Should return enc.blocks for standard ViT backbone"


def test_get_backbone_encoder_layers_trunk_blocks_path() -> None:
    """Verify _get_backbone_encoder_layers() returns enc.trunk.blocks for aimv2 backbones."""
    mock_blocks = nn.ModuleList([nn.Linear(1, 1) for _ in range(3)])
    mock_trunk = SimpleNamespace(blocks=mock_blocks)
    mock_encoder = SimpleNamespace(trunk=mock_trunk)  # no 'blocks' at top level
    mock_self = SimpleNamespace(backbone=[SimpleNamespace(encoder=mock_encoder)])

    result = LWDETR._get_backbone_encoder_layers(mock_self)  # type: ignore[arg-type]
    assert result is mock_blocks, "Should return enc.trunk.blocks for aimv2 backbone"
