import threading
import warnings

import numpy as np
import pytest

from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections
from supervision.detection.tools.inference_slicer import (
    InferenceSlicer,
    move_detections,
)
from supervision.detection.utils.iou_and_nms import OverlapFilter
from supervision.utils.internal import SupervisionWarnings


@pytest.fixture
def mock_callback():
    """Mock callback function for testing."""

    def callback(_: np.ndarray) -> Detections:
        return Detections(xyxy=np.array([[0, 0, 10, 10]]))

    return callback


@pytest.mark.parametrize(
    ("resolution_wh", "slice_wh", "overlap_wh", "expected_offsets"),
    [
        # Case 1: Square image, square slices, no overlap
        (
            (256, 256),
            (128, 128),
            (0, 0),
            np.array(
                [
                    [0, 0, 128, 128],
                    [128, 0, 256, 128],
                    [0, 128, 128, 256],
                    [128, 128, 256, 256],
                ]
            ),
        ),
        # Case 2: Square image, square slices, non-zero overlap
        (
            (256, 256),
            (128, 128),
            (64, 64),
            np.array(
                [
                    [0, 0, 128, 128],
                    [64, 0, 192, 128],
                    [128, 0, 256, 128],
                    [0, 64, 128, 192],
                    [64, 64, 192, 192],
                    [128, 64, 256, 192],
                    [0, 128, 128, 256],
                    [64, 128, 192, 256],
                    [128, 128, 256, 256],
                ]
            ),
        ),
        # Case 3: Rectangle image (horizontal), square slices, no overlap
        (
            (192, 128),
            (64, 64),
            (0, 0),
            np.array(
                [
                    [0, 0, 64, 64],
                    [64, 0, 128, 64],
                    [128, 0, 192, 64],
                    [0, 64, 64, 128],
                    [64, 64, 128, 128],
                    [128, 64, 192, 128],
                ]
            ),
        ),
        # Case 4: Rectangle image (horizontal), square slices, non-zero overlap
        (
            (192, 128),
            (64, 64),
            (32, 32),
            np.array(
                [
                    [0, 0, 64, 64],
                    [32, 0, 96, 64],
                    [64, 0, 128, 64],
                    [96, 0, 160, 64],
                    [128, 0, 192, 64],
                    [0, 32, 64, 96],
                    [32, 32, 96, 96],
                    [64, 32, 128, 96],
                    [96, 32, 160, 96],
                    [128, 32, 192, 96],
                    [0, 64, 64, 128],
                    [32, 64, 96, 128],
                    [64, 64, 128, 128],
                    [96, 64, 160, 128],
                    [128, 64, 192, 128],
                ]
            ),
        ),
        # Case 5: Rectangle image (vertical), square slices, no overlap
        (
            (128, 192),
            (64, 64),
            (0, 0),
            np.array(
                [
                    [0, 0, 64, 64],
                    [64, 0, 128, 64],
                    [0, 64, 64, 128],
                    [64, 64, 128, 128],
                    [0, 128, 64, 192],
                    [64, 128, 128, 192],
                ]
            ),
        ),
        # Case 6: Rectangle image (vertical), square slices, non-zero overlap
        (
            (128, 192),
            (64, 64),
            (32, 32),
            np.array(
                [
                    [0, 0, 64, 64],
                    [32, 0, 96, 64],
                    [64, 0, 128, 64],
                    [0, 32, 64, 96],
                    [32, 32, 96, 96],
                    [64, 32, 128, 96],
                    [0, 64, 64, 128],
                    [32, 64, 96, 128],
                    [64, 64, 128, 128],
                    [0, 96, 64, 160],
                    [32, 96, 96, 160],
                    [64, 96, 128, 160],
                    [0, 128, 64, 192],
                    [32, 128, 96, 192],
                    [64, 128, 128, 192],
                ]
            ),
        ),
        # Case 7: Square image, rectangular slices (horizontal), no overlap
        (
            (160, 160),
            (80, 40),
            (0, 0),
            np.array(
                [
                    [0, 0, 80, 40],
                    [80, 0, 160, 40],
                    [0, 40, 80, 80],
                    [80, 40, 160, 80],
                    [0, 80, 80, 120],
                    [80, 80, 160, 120],
                    [0, 120, 80, 160],
                    [80, 120, 160, 160],
                ]
            ),
        ),
        # Case 8: Square image, rectangular slices (vertical), non-zero overlap
        (
            (160, 160),
            (40, 80),
            (10, 20),
            np.array(
                [
                    [0, 0, 40, 80],
                    [30, 0, 70, 80],
                    [60, 0, 100, 80],
                    [90, 0, 130, 80],
                    [120, 0, 160, 80],
                    [0, 60, 40, 140],
                    [30, 60, 70, 140],
                    [60, 60, 100, 140],
                    [90, 60, 130, 140],
                    [120, 60, 160, 140],
                    [0, 80, 40, 160],
                    [30, 80, 70, 160],
                    [60, 80, 100, 160],
                    [90, 80, 130, 160],
                    [120, 80, 160, 160],
                ]
            ),
        ),
    ],
)
def test_generate_offset(
    resolution_wh: tuple[int, int],
    slice_wh: tuple[int, int],
    overlap_wh: tuple[int, int],
    expected_offsets: np.ndarray,
) -> None:
    offsets = InferenceSlicer._generate_offset(
        resolution_wh=resolution_wh,
        slice_wh=slice_wh,
        overlap_wh=overlap_wh,
    )

    assert np.array_equal(offsets, expected_offsets), (
        f"Expected {expected_offsets}, got {offsets}"
    )


def test_run_callback_warns_when_detections_outside_slice_bounds() -> None:
    """Test that a warning is emitted when callback returns detections with
    coordinates outside the slice bounds."""

    def out_of_bounds_callback(_: np.ndarray) -> Detections:
        # Return detections with coordinates exceeding the 64x64 slice size
        return Detections(
            xyxy=np.array([[0, 0, 128, 128]], dtype=float),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
        )

    image = np.zeros((128, 128, 3), dtype=np.uint8)
    slicer = InferenceSlicer(callback=out_of_bounds_callback, slice_wh=64, overlap_wh=0)

    with pytest.warns(SupervisionWarnings, match="outside the slice bounds"):
        slicer(image)


def test_run_callback_warns_only_once_for_out_of_bounds_detections() -> None:
    """Test that the out-of-bounds warning is only emitted once even across
    multiple slices."""

    def out_of_bounds_callback(_: np.ndarray) -> Detections:
        return Detections(
            xyxy=np.array([[0, 0, 128, 128]], dtype=float),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
        )

    image = np.zeros((256, 256, 3), dtype=np.uint8)
    slicer = InferenceSlicer(callback=out_of_bounds_callback, slice_wh=64, overlap_wh=0)

    with warnings.catch_warnings(record=True) as recorded_warnings:
        warnings.simplefilter("always")
        slicer(image)

    out_of_bounds_warnings = [
        w
        for w in recorded_warnings
        if issubclass(w.category, SupervisionWarnings)
        and "outside the slice bounds" in str(w.message)
    ]
    assert len(out_of_bounds_warnings) == 1


def test_run_callback_no_warning_when_detections_inside_slice_bounds() -> None:
    """Test that no warning is emitted when callback returns detections within
    the slice bounds."""

    def in_bounds_callback(_: np.ndarray) -> Detections:
        return Detections(
            xyxy=np.array([[0, 0, 10, 10]], dtype=float),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
        )

    image = np.zeros((128, 128, 3), dtype=np.uint8)
    slicer = InferenceSlicer(callback=in_bounds_callback, slice_wh=64, overlap_wh=0)

    with warnings.catch_warnings(record=True) as recorded_warnings:
        warnings.simplefilter("always")
        slicer(image)

    out_of_bounds_warnings = [
        w
        for w in recorded_warnings
        if issubclass(w.category, SupervisionWarnings)
        and "outside the slice bounds" in str(w.message)
    ]
    assert len(out_of_bounds_warnings) == 0


def test_run_callback_warns_when_detections_have_negative_coordinates() -> None:
    """Test that a warning is emitted when callback returns detections with
    negative coordinates, indicating wrong reference frame."""

    def negative_coords_callback(_: np.ndarray) -> Detections:
        # Return detections with negative coordinates (e.g., returned in full-image
        # coordinates that are to the left/top of this slice's origin)
        return Detections(
            xyxy=np.array([[-10, -10, 10, 10]], dtype=float),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
        )

    image = np.zeros((128, 128, 3), dtype=np.uint8)
    slicer = InferenceSlicer(
        callback=negative_coords_callback, slice_wh=64, overlap_wh=0
    )

    with pytest.warns(SupervisionWarnings, match="outside the slice bounds"):
        slicer(image)


def test_run_callback_warns_only_once_with_multiple_threads() -> None:
    """Test that exactly one warning fires even with thread_workers > 1, validating
    that the threading.Lock makes the check-and-set atomic."""

    def out_of_bounds_callback(_: np.ndarray) -> Detections:
        return Detections(
            xyxy=np.array([[0, 0, 128, 128]], dtype=float),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
        )

    # 512x512 / 64 slice -> 64 slices; all 4 threads will see out-of-bounds detections
    image = np.zeros((512, 512, 3), dtype=np.uint8)
    slicer = InferenceSlicer(
        callback=out_of_bounds_callback,
        slice_wh=64,
        overlap_wh=0,
        thread_workers=4,
    )

    with warnings.catch_warnings(record=True) as recorded_warnings:
        warnings.simplefilter("always")
        slicer(image)

    out_of_bounds_warnings = [
        w
        for w in recorded_warnings
        if issubclass(w.category, SupervisionWarnings)
        and "outside the slice bounds" in str(w.message)
    ]
    assert len(out_of_bounds_warnings) == 1


def test_run_callback_no_warning_for_detection_exactly_at_slice_boundary() -> None:
    """Test that a detection whose coordinates exactly equal the slice dimensions
    does not trigger the warning (boundary is exclusive: > not >=)."""

    def at_boundary_callback(_: np.ndarray) -> Detections:
        # x2=64, y2=64 on a 64x64 slice — touching the edge but not exceeding it
        return Detections(
            xyxy=np.array([[0, 0, 64, 64]], dtype=float),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
        )

    image = np.zeros((128, 128, 3), dtype=np.uint8)
    slicer = InferenceSlicer(callback=at_boundary_callback, slice_wh=64, overlap_wh=0)

    with warnings.catch_warnings(record=True) as recorded_warnings:
        warnings.simplefilter("always")
        slicer(image)

    out_of_bounds_warnings = [
        w
        for w in recorded_warnings
        if issubclass(w.category, SupervisionWarnings)
        and "outside the slice bounds" in str(w.message)
    ]
    assert len(out_of_bounds_warnings) == 0


def test_run_callback_does_not_rewarn_on_second_call() -> None:
    """Test that a second call to the same slicer instance does not re-emit
    the out-of-bounds warning even when detections are still out of bounds."""

    def out_of_bounds_callback(_: np.ndarray) -> Detections:
        return Detections(
            xyxy=np.array([[0, 0, 128, 128]], dtype=float),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
        )

    image = np.zeros((128, 128, 3), dtype=np.uint8)
    slicer = InferenceSlicer(callback=out_of_bounds_callback, slice_wh=64, overlap_wh=0)

    with warnings.catch_warnings(record=True) as recorded_warnings:
        warnings.simplefilter("always")
        slicer(image)  # first call — warning fires
        slicer(image)  # second call — must not re-warn

    out_of_bounds_warnings = [
        w
        for w in recorded_warnings
        if issubclass(w.category, SupervisionWarnings)
        and "outside the slice bounds" in str(w.message)
    ]
    assert len(out_of_bounds_warnings) == 1


def test_obb_callbacks_run_sequentially_even_with_multiple_workers() -> None:
    """Test that OBB callbacks are serialized even when thread_workers > 1."""

    active_calls = 0
    max_active_calls = 0
    concurrent_callbacks = 0
    callback_lock = threading.Lock()

    def obb_callback(_: np.ndarray) -> Detections:
        nonlocal active_calls, max_active_calls, concurrent_callbacks

        with callback_lock:
            active_calls += 1
            max_active_calls = max(max_active_calls, active_calls)
            if active_calls > 1:
                concurrent_callbacks += 1

        with callback_lock:
            active_calls -= 1

        return Detections(
            xyxy=np.array([[0, 0, 10, 10]], dtype=float),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
            data={
                ORIENTED_BOX_COORDINATES: np.array(
                    [[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=float
                )
            },
        )

    image = np.zeros((128, 128, 3), dtype=np.uint8)
    slicer = InferenceSlicer(
        callback=obb_callback,
        slice_wh=64,
        overlap_wh=0,
        thread_workers=4,
    )

    with pytest.warns(SupervisionWarnings, match="oriented bounding boxes"):
        detections = slicer(image)

    assert max_active_calls == 1
    assert concurrent_callbacks == 0
    assert len(detections) == 4


def _rotated_rect(
    cx: float, cy: float, w: float, h: float, angle_deg: float
) -> np.ndarray:
    angle = np.deg2rad(angle_deg)
    cos, sin = np.cos(angle), np.sin(angle)
    rot = np.array([[cos, -sin], [sin, cos]])
    corners = np.array(
        [[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]]
    )
    return (corners @ rot.T + [cx, cy]).astype(np.float32)


@pytest.mark.parametrize(
    "overlap_filter",
    [OverlapFilter.NON_MAX_SUPPRESSION, OverlapFilter.NON_MAX_MERGE],
)
def test_inference_slicer_keeps_crossed_obb_detections(
    overlap_filter: OverlapFilter,
) -> None:
    """Regression for issue #1679: the SAHI workflow with OBB detections
    dropped valid detections at the merge step because `with_nms`/`with_nmm`
    historically used axis-aligned IoU. For crossed thin rectangles the AABBs
    are nearly identical (IoU ≈ 1.0) while the OBBs barely overlap (IoU ≈ 0.06)
    — so AABB-NMS suppressed one of them.

    Both crossed OBBs must survive end-to-end through `InferenceSlicer`.
    """
    quad_a = _rotated_rect(50, 50, 80, 8, +45)
    quad_b = _rotated_rect(50, 50, 80, 8, -45)
    aabb_a = [
        quad_a[:, 0].min(),
        quad_a[:, 1].min(),
        quad_a[:, 0].max(),
        quad_a[:, 1].max(),
    ]
    aabb_b = [
        quad_b[:, 0].min(),
        quad_b[:, 1].min(),
        quad_b[:, 0].max(),
        quad_b[:, 1].max(),
    ]

    def callback(_: np.ndarray) -> Detections:
        return Detections(
            xyxy=np.array([aabb_a, aabb_b], dtype=np.float32),
            confidence=np.array([0.9, 0.85], dtype=np.float32),
            class_id=np.array([0, 0], dtype=int),
            data={ORIENTED_BOX_COORDINATES: np.stack([quad_a, quad_b])},
        )

    image = np.zeros((100, 100, 3), dtype=np.uint8)
    slicer = InferenceSlicer(
        callback=callback,
        slice_wh=100,
        overlap_wh=0,
        thread_workers=1,
        overlap_filter=overlap_filter,
        iou_threshold=0.5,
    )

    detections = slicer(image)

    assert len(detections) == 2


class TestInferenceSlicerBatch:
    """Tests for InferenceSlicer batch_size > 1 path."""

    @pytest.mark.parametrize(
        "batch_size",
        [
            pytest.param(0, id="zero"),
            pytest.param(-1, id="negative"),
        ],
    )
    def test_raises_on_invalid_batch_size(self, batch_size: int) -> None:
        """ValueError raised for batch_size < 1."""
        with pytest.raises(ValueError, match="batch_size"):
            InferenceSlicer(
                callback=lambda x: Detections.empty(), batch_size=batch_size
            )

    def test_batch_size_one_callback_receives_ndarray(self) -> None:
        """batch_size=1 delivers np.ndarray to callback, not list."""
        received_types: list[type] = []

        def callback(tile: np.ndarray) -> Detections:
            received_types.append(type(tile))
            return Detections.empty()

        image = np.zeros((128, 128, 3), dtype=np.uint8)
        slicer = InferenceSlicer(
            callback=callback, slice_wh=64, overlap_wh=0, batch_size=1
        )
        slicer(image)

        assert all(t is np.ndarray for t in received_types)

    def test_batch_callback_receives_list_of_ndarrays(self) -> None:
        """batch_size > 1 delivers list[np.ndarray] to callback."""
        received: list[list] = []

        def callback(tiles: list) -> list:
            received.append(tiles)
            return [Detections.empty() for _ in tiles]

        # 128x128, slice_wh=64, overlap=0 → 4 slices → 2 batches of 2
        image = np.zeros((128, 128, 3), dtype=np.uint8)
        slicer = InferenceSlicer(
            callback=callback, slice_wh=64, overlap_wh=0, batch_size=2
        )
        slicer(image)

        assert len(received) == 2
        assert all(isinstance(batch, list) for batch in received)
        assert all(isinstance(tile, np.ndarray) for batch in received for tile in batch)

    @pytest.mark.parametrize(
        ("image_wh", "batch_size", "expected_batch_sizes"),
        [
            pytest.param((320, 64), 3, [3, 2], id="5-slices-batch-3"),
            pytest.param((256, 64), 4, [4], id="4-slices-batch-4"),
            pytest.param((384, 64), 2, [2, 2, 2], id="6-slices-batch-2"),
        ],
    )
    def test_last_batch_shorter_when_not_divisible(
        self,
        image_wh: tuple[int, int],
        batch_size: int,
        expected_batch_sizes: list[int],
    ) -> None:
        """Last batch has remaining slices when total not divisible by batch_size."""
        received_batch_sizes: list[int] = []

        def callback(tiles: list) -> list:
            received_batch_sizes.append(len(tiles))
            return [Detections.empty() for _ in tiles]

        image = np.zeros((image_wh[1], image_wh[0], 3), dtype=np.uint8)
        slicer = InferenceSlicer(
            callback=callback,
            slice_wh=64,
            overlap_wh=0,
            batch_size=batch_size,
            thread_workers=1,
        )
        slicer(image)

        assert received_batch_sizes == expected_batch_sizes

    def test_batch_wrong_return_type_raises(self) -> None:
        """ValueError raised when batch callback returns Detections instead of list."""

        def callback(tiles: list) -> Detections:  # type: ignore[return]
            return Detections.empty()

        image = np.zeros((128, 128, 3), dtype=np.uint8)
        slicer = InferenceSlicer(
            callback=callback, slice_wh=64, overlap_wh=0, batch_size=2
        )
        with pytest.raises(ValueError, match="list\\[Detections\\]"):
            slicer(image)

    def test_batch_length_mismatch_raises(self) -> None:
        """ValueError raised when batch callback returns list of wrong length."""

        def callback(tiles: list) -> list:
            return [Detections.empty()]  # always 1, regardless of batch size

        # 128x128, batch_size=4 → one batch of 4 slices; callback returns 1
        image = np.zeros((128, 128, 3), dtype=np.uint8)
        slicer = InferenceSlicer(
            callback=callback, slice_wh=64, overlap_wh=0, batch_size=4
        )
        with pytest.raises(ValueError, match="Lengths must match"):
            slicer(image)

    def test_batch_with_thread_workers_merges_correctly(self) -> None:
        """batch_size + thread_workers > 1 yields correct merged detection count."""
        call_count = 0

        def callback(tiles: list) -> list:
            nonlocal call_count
            call_count += 1
            return [
                Detections(xyxy=np.array([[0, 0, 10, 10]], dtype=float)) for _ in tiles
            ]

        # 128x128, slice_wh=64, overlap=0 → 4 slices → 2 batches of 2
        image = np.zeros((128, 128, 3), dtype=np.uint8)
        slicer = InferenceSlicer(
            callback=callback,
            slice_wh=64,
            overlap_wh=0,
            batch_size=2,
            thread_workers=4,
            overlap_filter=OverlapFilter.NONE,
        )
        detections = slicer(image)

        assert call_count == 2
        assert len(detections) == 4

    def test_batch_obb_forces_sequential_and_warns(self) -> None:
        """OBB in first batch forces sequential execution and emits one warning."""
        active_calls = 0
        max_active_calls = 0
        lock = threading.Lock()

        def callback(tiles: list) -> list:
            nonlocal active_calls, max_active_calls
            with lock:
                active_calls += 1
                max_active_calls = max(max_active_calls, active_calls)
            with lock:
                active_calls -= 1
            return [
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=float),
                    data={
                        ORIENTED_BOX_COORDINATES: np.array(
                            [[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=float
                        )
                    },
                )
                for _ in tiles
            ]

        # 192x192, batch_size=2 → 9 slices → 5 batches; first sync, 4 remaining
        image = np.zeros((192, 192, 3), dtype=np.uint8)
        slicer = InferenceSlicer(
            callback=callback,
            slice_wh=64,
            overlap_wh=0,
            batch_size=2,
            thread_workers=4,
            overlap_filter=OverlapFilter.NONE,
        )

        with pytest.warns(SupervisionWarnings, match="oriented bounding boxes"):
            detections = slicer(image)

        assert max_active_calls == 1
        assert len(detections) == 9

    def test_batch_warns_out_of_bounds_once(self) -> None:
        """Out-of-slice-bounds warning fires exactly once in batch path."""

        def callback(tiles: list) -> list:
            return [
                Detections(xyxy=np.array([[0, 0, 512, 512]], dtype=float))
                for _ in tiles
            ]

        image = np.zeros((128, 128, 3), dtype=np.uint8)
        slicer = InferenceSlicer(
            callback=callback,
            slice_wh=64,
            overlap_wh=0,
            batch_size=2,
            overlap_filter=OverlapFilter.NONE,
        )
        with pytest.warns(SupervisionWarnings, match="outside the slice bounds"):
            slicer(image)

    def test_move_detections_returns_a_copy(self) -> None:
        """move_detections must not mutate the caller's Detections object."""
        detections = Detections(
            xyxy=np.array([[1.0, 2.0, 3.0, 4.0]], dtype=np.float32),
            class_id=np.array([0]),
        )
        original_xyxy = detections.xyxy.copy()

        moved = move_detections(
            detections=detections,
            offset=np.array([10, 20]),
            resolution_wh=(100, 100),
        )

        np.testing.assert_array_equal(detections.xyxy, original_xyxy)
        np.testing.assert_array_equal(moved.xyxy, np.array([[11.0, 22.0, 13.0, 24.0]]))
