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

from contextlib import contextmanager

import pytest
import torch

from rfdetr.models.heads.segmentation import DepthwiseConvBlock


@pytest.fixture(autouse=True)
def _reset_random_seeds() -> None:
    """Reset random seeds before each test for reproducibility."""
    torch.manual_seed(42)


@pytest.mark.parametrize(
    "device",
    [
        pytest.param("cpu", id="cpu"),
        pytest.param(
            "cuda",
            id="gpu",
            marks=[
                pytest.mark.gpu,
                pytest.mark.skipif(
                    not torch.cuda.is_available(),
                    reason="CUDA is not available",
                ),
            ],
        ),
    ],
)
def test_depthwise_conv_block_forward(device: str) -> None:
    """DepthwiseConvBlock forward pass produces correct output shape without error."""
    block = DepthwiseConvBlock(dim=8).to(device)
    x = torch.randn(1, 8, 4, 4, device=device)
    y = block(x)
    assert y.shape == x.shape


def test_depthwise_conv_forward_disables_cudnn(monkeypatch) -> None:
    """Depthwise conv should execute with cuDNN disabled during forward."""
    block = DepthwiseConvBlock(dim=8)
    enabled_calls: list[bool] = []
    original_flags = torch.backends.cudnn.flags

    @contextmanager
    def _tracking_flags(*, enabled: bool):
        enabled_calls.append(enabled)
        with original_flags(enabled=enabled):
            yield

    monkeypatch.setattr(torch.backends.cudnn, "flags", _tracking_flags)

    x = torch.randn(1, 8, 4, 4)
    y = block(x)
    assert y.shape == x.shape
    assert enabled_calls, "torch.backends.cudnn.flags was never called"
    assert all(not e for e in enabled_calls)


def test_depthwise_conv_backward_disables_cudnn(monkeypatch) -> None:
    """Backward pass must also run with cuDNN disabled (issue #731).

    The previous fix (PR #728) only wrapped the forward pass in a context manager.  The backward kernels ran with cuDNN
    re-enabled, causing RuntimeError on T4/P100 GPUs.
    """
    block = DepthwiseConvBlock(dim=8)
    enabled_calls: list[bool] = []
    original_flags = torch.backends.cudnn.flags

    @contextmanager
    def _tracking_flags(*, enabled: bool):
        enabled_calls.append(enabled)
        with original_flags(enabled=enabled):
            yield

    monkeypatch.setattr(torch.backends.cudnn, "flags", _tracking_flags)

    x = torch.randn(1, 8, 4, 4, requires_grad=True)
    y = block(x)
    y.sum().backward()

    assert x.grad is not None
    assert x.grad.shape == x.shape
    # cuDNN must be disabled for both forward and backward
    assert len(enabled_calls) >= 2
    assert all(not e for e in enabled_calls)


@pytest.mark.parametrize(
    "device",
    [
        pytest.param("cpu", id="cpu"),
        pytest.param(
            "cuda",
            id="gpu",
            marks=[
                pytest.mark.gpu,
                pytest.mark.skipif(
                    not torch.cuda.is_available(),
                    reason="CUDA is not available",
                ),
            ],
        ),
    ],
)
def test_depthwise_conv_backward_produces_correct_gradients(device: str) -> None:
    """Backward pass through DepthwiseConvBlock produces valid gradients."""
    block = DepthwiseConvBlock(dim=8).to(device)
    x = torch.randn(1, 8, 4, 4, device=device, requires_grad=True)
    y = block(x)
    y.sum().backward()
    assert x.grad is not None
    assert x.grad.shape == x.shape
    assert torch.isfinite(x.grad).all()


def test_depthwise_conv_gradients_match_reference() -> None:
    """Custom autograd Function gradients match nn.Conv2d gradients.

    Verifies that _DepthwiseConvWithoutCuDNN produces the same gradients as a standard nn.Conv2d forward+backward (run
    with cuDNN disabled globally).
    """
    torch.manual_seed(42)
    dim = 8
    block = DepthwiseConvBlock(dim=dim)

    # Reference: run standard nn.Conv2d with cuDNN globally disabled
    x_ref = torch.randn(1, dim, 4, 4, requires_grad=True)
    with torch.backends.cudnn.flags(enabled=False):
        y_ref = block.dwconv(x_ref)
    y_ref.sum().backward()

    x_ref_grad = x_ref.grad.clone()
    weight_ref_grad = block.dwconv.weight.grad.clone()
    bias_ref_grad = block.dwconv.bias.grad.clone()

    # Our implementation via _depthwise_conv.  zero_grad() so that the second
    # backward does not accumulate into weight.grad from the first run.
    block.zero_grad()
    x_test = x_ref.detach().clone().requires_grad_(True)
    y_test = block._depthwise_conv(x_test)
    y_test.sum().backward()

    assert torch.allclose(y_ref, y_test, atol=1e-6)
    assert torch.allclose(x_ref_grad, x_test.grad, atol=1e-6)
    assert torch.allclose(weight_ref_grad, block.dwconv.weight.grad, atol=1e-6)
    assert torch.allclose(bias_ref_grad, block.dwconv.bias.grad, atol=1e-6)


def test_depthwise_conv_backward_fp16_grad_output() -> None:
    """Backward must not crash when grad_output is fp16 (AMP 16-mixed on T4/P100).

    On T4/P100, trainer resolves amp=True to '16-mixed'.  In that mode the backward receives fp16 grad_output while the
    saved weight stays fp32. Without explicit dtype casting, conv2d_input raises:
        RuntimeError: expected scalar type Half but found Float
    """
    dim = 8
    block = DepthwiseConvBlock(dim=dim)
    x = torch.randn(1, dim, 4, 4, requires_grad=True)

    # Simulate 16-mixed backward: forward in fp32, grad_output arrives as fp16
    y = block._depthwise_conv(x)
    grad_output = torch.ones_like(y, dtype=torch.float16)
    y.backward(grad_output)

    assert x.grad is not None
    assert x.grad.dtype == torch.float32
    assert torch.isfinite(x.grad).all()


def test_depthwise_conv_backward_bf16_activation_keeps_grads_fp32() -> None:
    """grad_input and weight.grad must be fp32 when saved activation x is bf16 (issue #959).

    Under bf16-mixed AMP, the activation x entering _DepthwiseConvWithoutCuDNN is bf16 while weight stays fp32.  The old
    code cast grad_input back to x.dtype (bf16), propagating bf16 gradients to fp32 backbone parameters so that
    param.grad.dtype became bf16.  Fused AdamW then crashed with 'params, grads, exp_avgs, and exp_avg_sqs must have
    same dtype, device, and layout' (see issue #959).  The fix keeps grad_input in weight.dtype (fp32).

    This test drives the backward directly with a bf16 saved activation to reproduce the dtype that is present at
    training time without requiring a GPU.
    """
    import types

    from rfdetr.models.heads.segmentation import _DepthwiseConvWithoutCuDNN

    dim = 8
    weight = torch.randn(dim, 1, 3, 3, requires_grad=True)  # fp32 parameter (never cast by AMP)
    x_bf16 = torch.randn(1, dim, 4, 4, dtype=torch.bfloat16)  # bf16 activation (cast by AMP forward)
    grad_output = torch.ones(1, dim, 4, 4, dtype=torch.bfloat16)  # bf16 grad (from bf16 backward)

    # Build a minimal context mirroring what ctx would contain after the AMP forward pass.
    ctx = types.SimpleNamespace()
    ctx.saved_tensors = (x_bf16, weight)
    ctx.has_bias = False
    ctx.stride = (1, 1)
    ctx.padding = (1, 1)
    ctx.dilation = (1, 1)
    ctx.groups = dim
    ctx.needs_input_grad = [True, True, False, False, False, False, False]

    grad_input, grad_weight, *_ = _DepthwiseConvWithoutCuDNN.backward(ctx, grad_output)

    assert grad_input is not None, "grad_input should not be None when needs_input_grad[0] is True"
    assert grad_input.dtype == torch.float32, (
        f"grad_input is {grad_input.dtype} — bf16 grad_input propagates to fp32 backbone params "
        "and crashes fused AdamW (issue #959)"
    )
    assert grad_weight is not None, "grad_weight should not be None when needs_input_grad[1] is True"
    assert grad_weight.dtype == torch.float32, (
        f"grad_weight is {grad_weight.dtype} — weight grad must stay fp32 to match param dtype"
    )


def test_depthwise_conv_no_cudnn_bias_none() -> None:
    """_DepthwiseConvWithoutCuDNN forward and backward work correctly with bias=None.

    Exercises the ctx.has_bias=False branch in forward and the grad_bias=None return in backward — never reached via
    DepthwiseConvBlock (always has bias).
    """
    from rfdetr.models.heads.segmentation import _DepthwiseConvWithoutCuDNN

    dim = 8
    weight = torch.randn(dim, 1, 3, 3, requires_grad=True)
    x = torch.randn(1, dim, 4, 4, requires_grad=True)
    y = _DepthwiseConvWithoutCuDNN.apply(x, weight, None, (1, 1), (1, 1), (1, 1), dim)
    y_ref = torch.nn.functional.conv2d(x.detach(), weight.detach(), None, stride=1, padding=1, dilation=1, groups=dim)
    assert torch.allclose(y.detach(), y_ref, atol=1e-6)
    y.sum().backward()
    assert x.grad is not None
    assert x.grad.shape == x.shape
    assert torch.isfinite(x.grad).all()
    assert weight.grad is not None
    assert weight.grad.shape == weight.shape
    assert torch.isfinite(weight.grad).all()


@pytest.mark.parametrize(
    "layer_scale_init_value", [pytest.param(0, id="no_gamma"), pytest.param(1e-6, id="with_gamma")]
)
def test_depthwise_conv_block_layer_scale(layer_scale_init_value: float) -> None:
    """DepthwiseConvBlock with and without layer scaling produces valid output and gradients.

    Exercises the gamma=None (layer_scale_init_value=0) and gamma!=None (layer_scale_init_value>0) branches in
    DepthwiseConvBlock.forward().
    """
    block = DepthwiseConvBlock(dim=8, layer_scale_init_value=layer_scale_init_value)
    x = torch.randn(1, 8, 4, 4, requires_grad=True)
    y = block(x)
    assert y.shape == x.shape
    y.sum().backward()
    assert x.grad is not None
    assert torch.isfinite(x.grad).all()
    if layer_scale_init_value > 0:
        assert block.gamma is not None
        assert block.gamma.grad is not None
