# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------
"""Unit tests for :func:`rfdetr.detr._validate_shape_dims` and :func:`rfdetr.detr._resolve_patch_size`.

Tests call each helper directly so each validation path has a single focused test without the export/predict scaffolding
overhead.
"""

from types import SimpleNamespace

import pytest

from rfdetr.detr import _resolve_patch_size, _validate_shape_dims


class TestValidateShapeDimsHappyPath:
    """_validate_shape_dims returns normalised (height, width) for valid inputs."""

    def test_exact_plain_ints(self) -> None:
        """Plain int dims divisible by block_size are returned unchanged."""
        assert _validate_shape_dims((56, 112), 14, 14, 1) == (56, 112)

    def test_returns_plain_int_tuple(self) -> None:
        """Return type is always a tuple of plain Python int."""
        h, w = _validate_shape_dims((56, 56), 14, 14, 1)
        assert type(h) is int and type(w) is int

    def test_numpy_int_accepted(self) -> None:
        """numpy.int64 dims are accepted via operator.index and normalised."""
        import numpy as np

        h, w = _validate_shape_dims((np.int64(56), np.int64(112)), 14, 14, 1)
        assert (h, w) == (56, 112)
        assert type(h) is int and type(w) is int

    def test_non_square_shape(self) -> None:
        """Non-square (H != W) shapes are returned correctly."""
        assert _validate_shape_dims((112, 224), 14, 14, 1) == (112, 224)

    def test_block_size_from_num_windows(self) -> None:
        """block_size = patch_size * num_windows; both dims divisible by it."""
        # patch_size=16, num_windows=2 → block_size=32
        assert _validate_shape_dims((64, 128), 32, 16, 2) == (64, 128)


class TestValidateShapeDimsArityErrors:
    """_validate_shape_dims raises ValueError for non-two-element shapes."""

    def test_one_element_raises(self) -> None:
        """Single-element tuple must raise ValueError."""
        with pytest.raises(ValueError, match="shape must be a sequence"):
            _validate_shape_dims((56,), 14, 14, 1)

    def test_three_element_raises(self) -> None:
        """Three-element tuple must raise ValueError."""
        with pytest.raises(ValueError, match="shape must be a sequence"):
            _validate_shape_dims((56, 56, 3), 14, 14, 1)

    def test_scalar_raises(self) -> None:
        """Bare scalar (not a sequence) must raise ValueError."""
        with pytest.raises(ValueError, match="shape must be a sequence"):
            _validate_shape_dims(56, 14, 14, 1)  # type: ignore[arg-type]


class TestValidateShapeDimsInvalidDim:
    """_validate_shape_dims rejects bool, float, and non-positive dimension values."""

    @pytest.mark.parametrize("shape,match", [((True, 56), "height"), ((56, False), "width")])
    def test_bool_dim_raises(self, shape: tuple, match: str) -> None:
        """Bool dims must raise ValueError even though bool is an int subtype."""
        with pytest.raises(ValueError, match=f"{match} must be an integer"):
            _validate_shape_dims(shape, 14, 14, 1)  # type: ignore[arg-type]

    @pytest.mark.parametrize("shape", [(56.0, 56.0), (56.0, 56), (56, 56.0)])
    def test_float_dim_raises(self, shape: tuple) -> None:
        """Float dims must raise ValueError (operator.index rejects them)."""
        with pytest.raises(ValueError, match="must be an integer"):
            _validate_shape_dims(shape, 14, 14, 1)

    @pytest.mark.parametrize("shape", [(0, 56), (56, 0), (-14, 56), (56, -14)])
    def test_non_positive_dim_raises(self, shape: tuple[int, int]) -> None:
        """Zero or negative dims must raise ValueError."""
        with pytest.raises(ValueError, match="positive integers"):
            _validate_shape_dims(shape, 14, 14, 1)


class TestValidateShapeDimsDivisibilityCheck:
    """_validate_shape_dims enforces divisibility by block_size."""

    @pytest.mark.parametrize(
        "shape, block_size",
        [
            pytest.param((55, 56), 14, id="height_not_divisible"),
            pytest.param((56, 55), 14, id="width_not_divisible"),
            pytest.param((48, 64), 32, id="height_not_divisible_large_block"),
        ],
    )
    def test_indivisible_shape_raises(self, shape: tuple[int, int], block_size: int) -> None:
        """Shapes not divisible by block_size must raise ValueError."""
        with pytest.raises(ValueError, match=f"divisible by {block_size}"):
            _validate_shape_dims(shape, block_size, 14, 1)

    def test_error_message_includes_patch_size_and_num_windows(self) -> None:
        """Error message must name patch_size and num_windows for debuggability."""
        with pytest.raises(ValueError, match="patch_size=16") as exc_info:
            _validate_shape_dims((48, 64), 32, 16, 2)
        assert "num_windows=2" in str(exc_info.value)


class TestResolvePatchSize:
    """_resolve_patch_size resolves and validates patch_size for export()/predict()."""

    def _cfg(self, patch_size: int) -> SimpleNamespace:
        """Return a minimal model_config stub with the given patch_size.

        Examples:
            >>> TestResolvePatchSize()._cfg(16).patch_size
            16
        """
        return SimpleNamespace(patch_size=patch_size)

    def test_none_reads_from_model_config(self) -> None:
        """patch_size=None resolves to model_config.patch_size."""
        assert _resolve_patch_size(None, self._cfg(16), "export") == 16

    def test_none_falls_back_to_14_when_config_missing(self) -> None:
        """patch_size=None falls back to 14 when model_config has no patch_size."""
        assert _resolve_patch_size(None, SimpleNamespace(), "export") == 14

    def test_explicit_matching_config_accepted(self) -> None:
        """Providing patch_size equal to model_config.patch_size succeeds."""
        assert _resolve_patch_size(14, self._cfg(14), "export") == 14

    def test_explicit_mismatch_raises(self) -> None:
        """Providing patch_size != model_config.patch_size must raise ValueError."""
        with pytest.raises(ValueError, match="does not match"):
            _resolve_patch_size(16, self._cfg(14), "export")

    def test_mismatch_error_includes_caller_name(self) -> None:
        """Mismatch error message includes the caller name for context."""
        with pytest.raises(ValueError, match="predict"):
            _resolve_patch_size(16, self._cfg(14), "predict")

    @pytest.mark.parametrize("bad", [0, -1, True, False])
    def test_invalid_explicit_patch_size_raises(self, bad: int) -> None:
        """Non-positive-int patch_size must raise ValueError before the mismatch check."""
        cfg = SimpleNamespace(patch_size=bad)
        with pytest.raises(ValueError, match="patch_size must be a positive integer"):
            _resolve_patch_size(bad, cfg, "export")

    def test_invalid_config_patch_size_raises(self) -> None:
        """Bad patch_size in model_config (when caller passes None) must raise ValueError."""
        with pytest.raises(ValueError, match="patch_size must be a positive integer"):
            _resolve_patch_size(None, SimpleNamespace(patch_size=0), "export")
