from contextlib import ExitStack as DoesNotRaise
from typing import ClassVar

import numpy as np
import pytest
from matplotlib import pyplot as plt

from supervision import _cv2 as cv2
from supervision.dataset.core import DetectionDataset
from supervision.detection.core import Detections
from supervision.metrics.core import MetricTarget
from supervision.metrics.detection import (
    ConfusionMatrix,
    MeanAveragePrecision,
    _split_detections_by_outcome,
    _validate_input_tensors,
    detections_to_tensor,
)
from tests.helpers import (
    _create_detections,
    assert_almost_equal,
    create_predictions_with_class_iou_tests,
)


def _call_confusion_matrix_from_detections_masks() -> None:
    ConfusionMatrix.from_detections(
        predictions=[
            Detections(
                xyxy=np.zeros((1, 4), dtype=np.float32),
                class_id=np.array([0]),
                confidence=np.array([0.9]),
            )
        ],
        targets=[
            Detections(
                xyxy=np.zeros((1, 4), dtype=np.float32),
                class_id=np.array([0]),
            )
        ],
        classes=["box"],
        metric_target=MetricTarget.MASKS,
    )


def _call_confusion_matrix_from_tensors_masks() -> None:
    ConfusionMatrix.from_tensors(
        predictions=[np.zeros((1, 6), dtype=np.float32)],
        targets=[np.zeros((1, 5), dtype=np.float32)],
        classes=["box"],
        metric_target=MetricTarget.MASKS,
    )


def _call_confusion_matrix_evaluate_detection_batch_masks() -> None:
    ConfusionMatrix.evaluate_detection_batch(
        predictions=np.zeros((1, 6), dtype=np.float32),
        targets=np.zeros((1, 5), dtype=np.float32),
        num_classes=1,
        conf_threshold=0.3,
        iou_threshold=0.5,
        metric_target=MetricTarget.MASKS,
    )


class TestDetectionMetrics:
    """
    Verify that detection metrics are computed accurately.

    Ensures that detection metrics (mAP, Conf. Matrix, etc.) are computed accurately.
    These metrics are the primary way users evaluate the performance of their models
    within the `supervision` ecosystem.
    """

    CLASSES = np.arange(80)
    NUM_CLASSES = len(CLASSES)

    PREDICTIONS = np.array(
        [
            [2254, 906, 2447, 1353, 0.90538, 0],
            [2049, 1133, 2226, 1371, 0.59002, 56],
            [727, 1224, 838, 1601, 0.51119, 39],
            [808, 1214, 910, 1564, 0.45287, 39],
            [6, 52, 1131, 2133, 0.45057, 72],
            [299, 1225, 512, 1663, 0.45029, 39],
            [529, 874, 645, 945, 0.31101, 39],
            [8, 47, 1935, 2135, 0.28192, 72],
            [2265, 813, 2328, 901, 0.2714, 62],
        ],
        dtype=np.float32,
    )

    TARGET_TENSORS: ClassVar[list[np.ndarray]] = [
        np.array(
            [
                [2254, 906, 2447, 1353, 0],
                [2049, 1133, 2226, 1371, 56],
                [727, 1224, 838, 1601, 39],
                [808, 1214, 910, 1564, 39],
                [6, 52, 1131, 2133, 72],
                [299, 1225, 512, 1663, 39],
                [529, 874, 645, 945, 39],
                [8, 47, 1935, 2135, 72],
                [2265, 813, 2328, 901, 62],
            ]
        )
    ]

    DETECTIONS = Detections(
        xyxy=PREDICTIONS[:, :4],
        confidence=PREDICTIONS[:, 4],
        class_id=PREDICTIONS[:, 5].astype(int),
    )
    CERTAIN_DETECTIONS = Detections(
        xyxy=PREDICTIONS[:, :4],
        confidence=np.ones(len(PREDICTIONS)),
        class_id=PREDICTIONS[:, 5].astype(int),
    )

    DETECTION_TENSORS: ClassVar[list[np.ndarray]] = [
        np.concatenate(
            [
                det.xyxy,
                np.expand_dims(det.class_id, 1),
                np.expand_dims(det.confidence, 1),
            ],
            axis=1,
        )
        for det in [DETECTIONS]
    ]
    CERTAIN_DETECTION_TENSORS: ClassVar[list[np.ndarray]] = [
        np.concatenate(
            [
                det.xyxy,
                np.expand_dims(det.class_id, 1),
                np.ones((len(det), 1)),
            ],
            axis=1,
        )
        for det in [DETECTIONS]
    ]

    IDEAL_MATCHES = np.stack(
        [
            np.arange(len(PREDICTIONS)),
            np.arange(len(PREDICTIONS)),
            np.ones(len(PREDICTIONS)),
        ],
        axis=1,
    )

    @staticmethod
    def create_empty_conf_matrix(num_classes: int, do_add_dummy_class: bool = True):
        if do_add_dummy_class:
            num_classes += 1
        return np.zeros((num_classes, num_classes))

    @staticmethod
    def update_ideal_conf_matrix(conf_matrix: np.ndarray, class_ids: np.ndarray):
        for class_id, count in zip(*np.unique(class_ids, return_counts=True)):
            class_id = int(class_id)
            conf_matrix[class_id, class_id] += count
        return conf_matrix

    @staticmethod
    def worsen_ideal_conf_matrix(conf_matrix: np.ndarray, class_ids: np.ndarray | list):
        for class_id in class_ids:
            class_id = int(class_id)
            conf_matrix[class_id, class_id] -= 1
            conf_matrix[class_id, 80] += 1
        return conf_matrix

    IDEAL_CONF_MATRIX = create_empty_conf_matrix.__func__(NUM_CLASSES)
    IDEAL_CONF_MATRIX = update_ideal_conf_matrix.__func__(
        IDEAL_CONF_MATRIX, PREDICTIONS[:, 5]
    )

    GOOD_CONF_MATRIX = worsen_ideal_conf_matrix.__func__(
        IDEAL_CONF_MATRIX.copy(), [62, 72]
    )

    BAD_CONF_MATRIX = worsen_ideal_conf_matrix.__func__(
        IDEAL_CONF_MATRIX.copy(), [62, 72, 72, 39, 39, 39, 39, 56]
    )

    @pytest.mark.parametrize(
        (
            "detections",
            "with_confidence",
            "metric_target",
            "expected_result",
            "exception",
        ),
        [
            (
                Detections.empty(),
                False,
                MetricTarget.BOXES,
                np.empty((0, 5), dtype=np.float32),
                DoesNotRaise(),
            ),  # empty detections; no confidence
            (
                Detections.empty(),
                True,
                MetricTarget.BOXES,
                np.empty((0, 6), dtype=np.float32),
                DoesNotRaise(),
            ),  # empty detections; with confidence
            (
                Detections.empty(),
                False,
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                np.empty((0, 9), dtype=np.float32),
                DoesNotRaise(),
            ),  # empty OBB detections; no confidence
            (
                Detections.empty(),
                True,
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                np.empty((0, 10), dtype=np.float32),
                DoesNotRaise(),
            ),  # empty OBB detections; with confidence
            (
                _create_detections(
                    xyxy=[[0, 0, 10, 10]], class_id=[0], confidence=[0.5]
                ),
                False,
                MetricTarget.BOXES,
                np.array([[0, 0, 10, 10, 0]], dtype=np.float32),
                DoesNotRaise(),
            ),  # single detection; no confidence
            (
                _create_detections(
                    xyxy=[[0, 0, 10, 10]], class_id=[0], confidence=[0.5]
                ),
                True,
                MetricTarget.BOXES,
                np.array([[0, 0, 10, 10, 0, 0.5]], dtype=np.float32),
                DoesNotRaise(),
            ),  # single detection; with confidence
            (
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=np.array([0]),
                    confidence=np.array([0.5], dtype=np.float32),
                    data={
                        "xyxyxyxy": np.array(
                            [[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=np.float32
                        )
                    },
                ),
                False,
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                np.array([[0, 0, 10, 0, 10, 10, 0, 10, 0]], dtype=np.float32),
                DoesNotRaise(),
            ),  # single OBB detection; no confidence
            (
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=np.array([0]),
                    confidence=np.array([0.5], dtype=np.float32),
                    data={
                        "xyxyxyxy": np.array(
                            [[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=np.float32
                        )
                    },
                ),
                True,
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                np.array([[0, 0, 10, 0, 10, 10, 0, 10, 0, 0.5]], dtype=np.float32),
                DoesNotRaise(),
            ),  # single OBB detection; with confidence
            (
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=np.array([0]),
                    confidence=np.array([0.5], dtype=np.float32),
                ),
                False,
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                None,
                pytest.raises(ValueError, match="ORIENTED_BOUNDING_BOXES requested"),
            ),  # OBB requested but data missing
            (
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=np.array([0]),
                    confidence=np.array([0.5], dtype=np.float32),
                ),
                False,
                MetricTarget.MASKS,
                None,
                pytest.raises(ValueError, match=r"MetricTarget\.MASKS"),
            ),  # MASKS requested but not supported
            (
                _create_detections(
                    xyxy=[[0, 0, 10, 10], [0, 0, 20, 20]],
                    class_id=[0, 1],
                    confidence=[0.5, 0.2],
                ),
                False,
                MetricTarget.BOXES,
                np.array([[0, 0, 10, 10, 0], [0, 0, 20, 20, 1]], dtype=np.float32),
                DoesNotRaise(),
            ),  # multiple detections; no confidence
            (
                _create_detections(
                    xyxy=[[0, 0, 10, 10], [0, 0, 20, 20]],
                    class_id=[0, 1],
                    confidence=[0.5, 0.2],
                ),
                True,
                MetricTarget.BOXES,
                np.array(
                    [[0, 0, 10, 10, 0, 0.5], [0, 0, 20, 20, 1, 0.2]], dtype=np.float32
                ),
                DoesNotRaise(),
            ),  # multiple detections; with confidence
            pytest.param(
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=np.array([0]),
                    data={"xyxyxyxy": np.zeros((1, 4), dtype=np.float32)},
                ),
                False,
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                None,
                pytest.raises(ValueError, match="Expected xyxyxyxy to contain"),
                id="obb-malformed-element-count",
            ),  # OBB data present but wrong element count (4 instead of 8)
            pytest.param(
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=np.array([0]),
                    data={
                        "xyxyxyxy": np.array(
                            [[0, 0, 10, 0, 10, 10, 0, 10]], dtype=np.float32
                        )
                    },
                ),
                True,
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                None,
                pytest.raises(ValueError, match="Detections with confidence"),
                id="obb-with-confidence-but-confidence-none",
            ),  # OBB + with_confidence=True but confidence is None
            pytest.param(
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=None,
                    data={
                        "xyxyxyxy": np.array(
                            [[0, 0, 10, 0, 10, 10, 0, 10]], dtype=np.float32
                        )
                    },
                ),
                False,
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                None,
                pytest.raises(ValueError, match="class_id"),
                id="obb-class-id-none",
            ),  # OBB + class_id=None
        ],
    )
    def test_detections_to_tensor(
        self,
        detections: Detections,
        with_confidence: bool,
        metric_target: MetricTarget,
        expected_result: np.ndarray | None,
        exception: Exception,
    ) -> None:
        """
        Verify that Detections objects are correctly converted to NumPy tensors.

        Scenario: Converting Detections objects to NumPy tensors.
        Expected: Tensors are correctly formatted for consumption by metric functions,
        preserving coordinates, class IDs, and optionally confidence scores.
        """
        with exception:
            result = detections_to_tensor(
                detections=detections,
                with_confidence=with_confidence,
                metric_target=metric_target,
            )
            np.testing.assert_allclose(result, expected_result, atol=1e-5)

    @pytest.mark.parametrize(
        (
            "predictions",
            "targets",
            "classes",
            "conf_threshold",
            "iou_threshold",
            "expected_result",
            "exception",
        ),
        [
            (
                DETECTION_TENSORS,
                TARGET_TENSORS,
                CLASSES,
                0.2,
                0.5,
                IDEAL_CONF_MATRIX,
                DoesNotRaise(),
            ),
            (
                [],
                [],
                CLASSES,
                0.2,
                0.5,
                create_empty_conf_matrix.__func__(NUM_CLASSES),
                DoesNotRaise(),
            ),
            (
                DETECTION_TENSORS,
                TARGET_TENSORS,
                CLASSES,
                0.3,
                0.5,
                GOOD_CONF_MATRIX,
                DoesNotRaise(),
            ),
            (
                DETECTION_TENSORS,
                TARGET_TENSORS,
                CLASSES,
                0.6,
                0.5,
                BAD_CONF_MATRIX,
                DoesNotRaise(),
            ),
            (
                [
                    np.array(
                        [
                            # correct detection of [0]
                            [0.0, 0.0, 3.0, 3.0, 0, 0.9],
                            # additional detection of [0] - FP
                            [0.1, 0.1, 3.0, 3.0, 0, 0.9],
                            # correct detection with incorrect class
                            [6.0, 1.0, 8.0, 3.0, 1, 0.8],
                            # incorrect detection - FP
                            [1.0, 6.0, 2.0, 7.0, 1, 0.8],
                            # incorrect detection with low IoU - FP
                            [1.0, 2.0, 2.0, 4.0, 1, 0.8],
                        ]
                    )
                ],
                [
                    np.array(
                        [  # [0] detected
                            [0.0, 0.0, 3.0, 3.0, 0],
                            # [1] undetected - FN
                            [2.0, 2.0, 5.0, 5.0, 1],
                            # [2] correct detection with incorrect class
                            [6.0, 1.0, 8.0, 3.0, 2],
                        ]
                    )
                ],
                CLASSES[:3],
                0.6,
                0.5,
                np.array([[1, 0, 0, 0], [0, 0, 0, 1], [0, 1, 0, 0], [1, 2, 0, 0]]),
                DoesNotRaise(),
            ),
            (
                [
                    np.array(
                        [
                            # correct detection of [0]
                            [0.0, 0.0, 3.0, 3.0, 0, 0.9],
                            # additional detection of [0] - FP
                            [0.1, 0.1, 3.0, 3.0, 0, 0.9],
                            # correct detection with incorrect class
                            [6.0, 1.0, 8.0, 3.0, 1, 0.8],
                            # incorrect detection - FP
                            [1.0, 6.0, 2.0, 7.0, 1, 0.8],
                            # incorrect detection with low IoU - FP
                            [1.0, 2.0, 2.0, 4.0, 1, 0.8],
                        ]
                    )
                ],
                [
                    np.array(
                        [
                            # [0] detected
                            [0.0, 0.0, 3.0, 3.0, 0],
                            # [1] undetected - FN
                            [2.0, 2.0, 5.0, 5.0, 1],
                            # [2] correct detection with incorrect class
                            [6.0, 1.0, 8.0, 3.0, 2],
                        ]
                    )
                ],
                CLASSES[:3],
                0.6,
                1.0,
                np.array([[0, 0, 0, 1], [0, 0, 0, 1], [0, 0, 0, 1], [2, 3, 0, 0]]),
                DoesNotRaise(),
            ),
        ],
    )
    def test_from_tensors(
        self,
        predictions,
        targets,
        classes,
        conf_threshold,
        iou_threshold,
        expected_result: np.ndarray | None,
        exception: Exception,
    ) -> None:
        with exception:
            result = ConfusionMatrix.from_tensors(
                predictions=predictions,
                targets=targets,
                classes=classes,
                conf_threshold=conf_threshold,
                iou_threshold=iou_threshold,
            )

        assert result.matrix.diagonal().sum() == expected_result.diagonal().sum()
        assert np.array_equal(result.matrix, expected_result)

    @pytest.mark.parametrize(
        (
            "predictions",
            "targets",
            "num_classes",
            "conf_threshold",
            "iou_threshold",
            "expected_result",
            "exception",
        ),
        [
            (
                DETECTION_TENSORS[0],
                TARGET_TENSORS[0],
                NUM_CLASSES,
                0.2,
                0.5,
                IDEAL_CONF_MATRIX,
                DoesNotRaise(),
            )
        ],
    )
    def test_evaluate_detection_batch(
        self,
        predictions,
        targets,
        num_classes,
        conf_threshold,
        iou_threshold,
        expected_result: np.ndarray | None,
        exception: Exception,
    ) -> None:
        with exception:
            result = ConfusionMatrix.evaluate_detection_batch(
                predictions=predictions,
                targets=targets,
                num_classes=num_classes,
                conf_threshold=conf_threshold,
                iou_threshold=iou_threshold,
            )

        assert result.diagonal().sum() == result.sum()
        assert np.array_equal(result, expected_result)

    def test_evaluate_detection_batch_rejects_negative_class_ids(self) -> None:
        """Negative class ids must fail instead of indexing the matrix tail."""
        predictions = np.array([[0, 0, 10, 10, 0, 0.9]], dtype=np.float32)
        targets = np.array([[0, 0, 10, 10, -1]], dtype=np.float32)

        with pytest.raises(ValueError, match="Target class ids"):
            ConfusionMatrix.evaluate_detection_batch(
                predictions=predictions,
                targets=targets,
                num_classes=1,
                conf_threshold=0.3,
                iou_threshold=0.5,
            )

    def test_evaluate_detection_batch_rejects_overflowing_class_ids(self) -> None:
        """Large class ids must fail instead of overflowing through int16 casts."""
        predictions = np.array([[0, 0, 10, 10, 40000, 0.9]], dtype=np.float32)
        targets = np.zeros((0, 5), dtype=np.float32)

        with pytest.raises(ValueError, match="Prediction class ids"):
            ConfusionMatrix.evaluate_detection_batch(
                predictions=predictions,
                targets=targets,
                num_classes=1,
                conf_threshold=0.3,
                iou_threshold=0.5,
            )

    @pytest.mark.parametrize(
        ("matches", "expected_result", "exception"),
        [
            (
                IDEAL_MATCHES,
                IDEAL_MATCHES,
                DoesNotRaise(),
            )
        ],
    )
    def test_drop_extra_matches(
        self,
        matches,
        expected_result: np.ndarray | None,
        exception: Exception,
    ) -> None:
        with exception:
            result = ConfusionMatrix._drop_extra_matches(matches)

            assert np.array_equal(result, expected_result)

    @pytest.mark.parametrize(
        ("recall", "precision", "expected_result", "exception"),
        [
            (
                np.array([1.0]),
                np.array([1.0]),
                1.0,
                DoesNotRaise(),
            ),  # perfect recall and precision
            (
                np.array([0.0]),
                np.array([0.0]),
                0.0,
                DoesNotRaise(),
            ),  # no recall and precision
            (
                np.array([0.0, 0.2, 0.2, 0.8, 0.8, 1.0]),
                np.array([0.7, 0.8, 0.4, 0.5, 0.1, 0.2]),
                0.5,
                DoesNotRaise(),
            ),
            (
                np.array([0.0, 0.5, 0.5, 1.0]),
                np.array([0.75, 0.75, 0.75, 0.75]),
                0.75,
                DoesNotRaise(),
            ),
        ],
    )
    def test_compute_average_precision(
        self,
        recall: np.ndarray,
        precision: np.ndarray,
        expected_result: float,
        exception: Exception,
    ) -> None:
        """
        Verify that Average Precision is correctly calculated from PR curve points.

        Scenario: Computing Average Precision (AP) from PR curve points.
        Expected: AP is correctly calculated using the area under the curve, which is
        the standard for evaluating detection models (mAP components).
        """
        with exception:
            result = MeanAveragePrecision.compute_average_precision(
                recall=recall, precision=precision
            )
            assert_almost_equal(result, expected_result, tolerance=0.01)

    def test_compute_average_precision_perfect_curve_is_exact_one(self) -> None:
        """COCO 101-point AP should give exactly 1.0 for a perfect PR curve."""
        result = MeanAveragePrecision.compute_average_precision(
            recall=np.array([1.0]),
            precision=np.array([1.0]),
        )

        assert result == pytest.approx(1.0, abs=1e-12)

    @pytest.mark.parametrize(
        (
            "predictions",
            "targets",
            "classes",
            "conf_threshold",
            "iou_threshold",
            "expected_result",
            "exception",
        ),
        [
            # Test 1: Class priority over IoU - correct class with lower IoU should win
            (
                [
                    _create_detections(  # Predicted bboxes
                        xyxy=[[0.1, 0.1, 2.1, 2.1], [0.0, 0.0, 2.0, 2.0]],
                        class_id=[0, 1],
                        confidence=[0.9, 0.95],
                    )
                ],
                [_create_detections(xyxy=[[0, 0, 2, 2]], class_id=[0])],  # GT bboxes
                [0, 1, 2],  # Class ids
                0.5,  # Confidence Threshold
                0.5,  # IOU Threshold
                np.array(  # Expected confusion matrix
                    [
                        [1.0, 0.0, 0.0, 0.0],  # 1 TP
                        [0.0, 0.0, 0.0, 0.0],  # none
                        [0.0, 0.0, 0.0, 0.0],  # none
                        [0.0, 1.0, 0.0, 0.0],  # 1 FP:
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 2: Multiple overlapping predictions with different classes
            (
                [
                    _create_detections(
                        xyxy=[
                            [0.1, 0.1, 2.1, 2.1],
                            [0.2, 0.2, 2.2, 2.2],
                            [0.3, 0.3, 2.3, 2.3],
                            [4.1, 4.1, 6.1, 6.1],
                        ],
                        class_id=[0, 1, 2, 1],
                        confidence=[0.9, 0.8, 0.7, 0.85],
                    )
                ],
                [
                    _create_detections(
                        xyxy=[[0, 0, 2, 2], [4, 4, 6, 6]], class_id=[0, 1]
                    )
                ],
                [0, 1, 2],
                0.5,
                0.5,
                np.array(
                    [
                        [1.0, 0.0, 0.0, 0.0],  # 1 TP
                        [0.0, 1.0, 0.0, 0.0],  # 1 TP
                        [0.0, 0.0, 0.0, 0.0],  # none
                        [0.0, 1.0, 1.0, 0.0],  # 2 FP
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 3: Confidence threshold filtering with edge cases
            (
                [
                    _create_detections(
                        xyxy=[[0, 0, 2, 2], [4, 4, 6, 6], [8, 8, 10, 10]],
                        class_id=[0, 1, 2],
                        confidence=[0.6, 0.4, 0.8],  # middle one below threshold
                    )
                ],
                [
                    _create_detections(
                        xyxy=[[0, 0, 2, 2], [4, 4, 6, 6]], class_id=[0, 1]
                    )
                ],
                [0, 1, 2],
                0.5,
                0.5,
                np.array(
                    [
                        [1.0, 0.0, 0.0, 0.0],  # 1 TP
                        [0.0, 0.0, 0.0, 1.0],  # 1 FN (filtered by conf)
                        [0.0, 0.0, 0.0, 0.0],  # none
                        [0.0, 0.0, 1.0, 0.0],  # 1 FP
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 4: IoU threshold boundary (IoU = 0.5625, slightly above threshold)
            (
                [
                    _create_detections(
                        xyxy=[
                            [0, 0, 1.5, 1.5],
                            [4, 4, 5.5, 5.5],
                        ],  # IoU = 0.5625 for both
                        class_id=[0, 1],
                        confidence=[0.9, 0.8],
                    )
                ],
                [
                    _create_detections(
                        xyxy=[[0, 0, 2, 2], [4, 4, 6, 6]], class_id=[0, 1]
                    )
                ],
                [0, 1, 2],
                0.5,
                0.5,
                np.array(
                    [
                        [1.0, 0.0, 0.0, 0.0],  # 1 TP (IoU exceeds threshold)
                        [0.0, 1.0, 0.0, 0.0],  # 1 TP (IoU exceeds threshold)
                        [0.0, 0.0, 0.0, 0.0],  # none
                        [0.0, 0.0, 0.0, 0.0],  # none
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 5: Chain of overlapping detections
            (
                [
                    _create_detections(
                        xyxy=[[0.1, 0.1, 2.1, 2.1], [1.9, 1.9, 3.9, 3.9]],
                        class_id=[0, 2],
                        confidence=[0.9, 0.8],
                    )
                ],
                [
                    _create_detections(
                        xyxy=[[0, 0, 2, 2], [1, 1, 3, 3], [2, 2, 4, 4]],
                        class_id=[0, 1, 2],
                    )
                ],
                [0, 1, 2],
                0.5,
                0.5,
                np.array(
                    [
                        [1.0, 0.0, 0.0, 0.0],  # 1 TP
                        [0.0, 0.0, 0.0, 1.0],  # 1 FN (no matching label)
                        [0.0, 0.0, 1.0, 0.0],  # 1 TP
                        [0.0, 0.0, 0.0, 0.0],  # none
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 6: All false positives (no ground truth)
            (
                [
                    _create_detections(
                        xyxy=[[0, 0, 2, 2], [4, 4, 6, 6], [8, 8, 10, 10]],
                        class_id=[0, 1, 2],
                        confidence=[0.9, 0.8, 0.7],
                    )
                ],
                [
                    _create_detections(
                        xyxy=np.empty((0, 4)), class_id=np.array([], dtype=int)
                    )
                ],
                [0, 1, 2],
                0.5,
                0.5,
                np.array(
                    [
                        [0.0, 0.0, 0.0, 0.0],  # none
                        [0.0, 0.0, 0.0, 0.0],  # none
                        [0.0, 0.0, 0.0, 0.0],  # none
                        [1.0, 1.0, 1.0, 0.0],  # 3 FP
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 7: Empty predictions and empty ground truth
            (
                [
                    _create_detections(
                        xyxy=np.empty((0, 4)),
                        class_id=np.array([], dtype=int),
                        confidence=np.array([], dtype=float),
                    )
                ],
                [
                    _create_detections(
                        xyxy=np.empty((0, 4)), class_id=np.array([], dtype=int)
                    )
                ],
                [0, 1, 2],
                0.5,
                0.5,
                np.zeros((4, 4)),
                DoesNotRaise(),
            ),
            # Test 8: Multi-class misclassifications
            (
                [
                    _create_detections(
                        xyxy=[[0, 0, 2, 2], [4, 4, 6, 6], [10, 10, 12, 12]],
                        class_id=[0, 2, 1],
                        confidence=[0.9, 0.8, 0.7],
                    )
                ],
                [
                    _create_detections(
                        xyxy=[[0, 0, 2, 2], [4, 4, 6, 6], [8, 8, 10, 10]],
                        class_id=[0, 1, 2],
                    )
                ],
                [0, 1, 2],
                0.5,
                0.5,
                np.array(
                    [
                        [1.0, 0.0, 0.0, 0.0],  # 1 TP
                        [0.0, 0.0, 1.0, 0.0],  # 1 misclassified
                        [0.0, 0.0, 0.0, 1.0],  # 1 FN
                        [0.0, 1.0, 0.0, 0.0],  # 1 FP
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 9: Complex multiple predictions with mixed results
            (
                [
                    _create_detections(
                        xyxy=[
                            [0, 0, 2, 2],
                            [4, 4, 6, 6],
                            [8, 8, 10, 10],
                            [12, 12, 14, 14],
                            [16, 16, 18, 18],
                        ],
                        class_id=[0, 1, 1, 2, 2],
                        confidence=[0.9, 0.8, 0.7, 0.6, 0.5],
                    )
                ],
                [
                    _create_detections(
                        xyxy=[
                            [0, 0, 2, 2],
                            [4, 4, 6, 6],
                            [8, 8, 10, 10],
                            [12, 12, 14, 14],
                        ],
                        class_id=[0, 1, 2, 0],
                    )
                ],
                [0, 1, 2],
                0.5,
                0.5,
                np.array(
                    [
                        [1.0, 0.0, 1.0, 0.0],  # 1 TP and 1 misclassified
                        [0.0, 1.0, 0.0, 0.0],  # 1 TP
                        [0.0, 1.0, 0.0, 0.0],  # 1 misclassified
                        [0.0, 0.0, 1.0, 0.0],  # 1 FP
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 10: Large complex example with confidence filtering
            (
                [
                    _create_detections(
                        xyxy=[
                            [0, 0, 2, 2],
                            [4, 4, 6, 6],
                            [8, 8, 10, 10],
                            [12, 12, 14, 14],
                            [16, 16, 18, 18],
                            [18, 18, 20, 20],
                        ],
                        class_id=[0, 0, 1, 2, 1, 2],
                        confidence=[0.9, 0.8, 0.7, 0.6, 0.5, 0.4],  # last one filtered
                    )
                ],
                [
                    _create_detections(
                        xyxy=[
                            [0, 0, 2, 2],
                            [4, 4, 6, 6],
                            [8, 8, 10, 10],
                            [12, 12, 14, 14],
                        ],
                        class_id=[0, 1, 2, 0],
                    )
                ],
                [0, 1, 2],
                0.5,  # conf_threshold filters out last prediction
                0.5,
                np.array(
                    [
                        [1.0, 0.0, 1.0, 0.0],  # 1 TP and 1 misclassified
                        [1.0, 0.0, 0.0, 0.0],  # 1 misclassified
                        [0.0, 1.0, 0.0, 0.0],  # 1 misclassified
                        [0.0, 1.0, 0.0, 0.0],  # 1 FP
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 11: High counts with multiple TPs and misclassifications
            (
                [
                    _create_detections(
                        xyxy=[
                            [0, 0, 2, 2],
                            [0, 3, 2, 5],
                            [0, 6, 2, 8],
                            [4, 0, 6, 2],
                            [4, 3, 6, 5],
                            [8, 0, 10, 2],
                            [12, 0, 14, 2],
                        ],
                        class_id=[0, 0, 0, 2, 2, 2, 0],
                        confidence=[0.95, 0.95, 0.95, 0.9, 0.9, 0.9, 0.8],
                    )
                ],
                [
                    _create_detections(
                        xyxy=[
                            [0, 0, 2, 2],
                            [0, 3, 2, 5],
                            [0, 6, 2, 8],
                            [4, 0, 6, 2],
                            [4, 3, 6, 5],
                            [8, 0, 10, 2],
                            [8, 3, 10, 5],
                        ],
                        class_id=[0, 0, 0, 1, 1, 2, 2],
                    )
                ],
                [0, 1, 2],
                0.5,
                0.5,
                np.array(
                    [
                        [3.0, 0.0, 0.0, 0.0],  # 3 TP
                        [0.0, 0.0, 2.0, 0.0],  # 2 misclassified
                        [0.0, 0.0, 1.0, 1.0],  # 1 TP, 1 FN
                        [1.0, 0.0, 0.0, 0.0],  # 1 FP
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 12: Symmetric multi-class confusions with higher counts
            (
                [
                    _create_detections(
                        xyxy=[
                            [0, 0, 2, 2],
                            [0, 4, 2, 6],
                            [4, 0, 6, 2],
                            [4, 4, 6, 6],
                            [8, 0, 10, 2],
                            [8, 4, 10, 6],
                            [12, 0, 14, 2],
                            [12, 4, 14, 6],
                        ],
                        class_id=[0, 0, 1, 1, 0, 0, 1, 1],
                        confidence=[0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.8, 0.8],
                    )
                ],
                [
                    _create_detections(
                        xyxy=[
                            [0, 0, 2, 2],
                            [0, 4, 2, 6],
                            [4, 0, 6, 2],
                            [4, 4, 6, 6],
                            [8, 0, 10, 2],
                            [8, 4, 10, 6],
                        ],
                        class_id=[0, 0, 1, 1, 2, 2],
                    )
                ],
                [0, 1, 2],  # Class ids
                0.5,  # Confidence threshold
                0.5,  # IOU threshold
                np.array(
                    [
                        [2.0, 0.0, 0.0, 0.0],  # 2 TP
                        [0.0, 2.0, 0.0, 0.0],  # TP
                        [2.0, 0.0, 0.0, 0.0],  # 2 misclassified
                        [0.0, 2.0, 0.0, 0.0],  # 2 FP
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 13: Empty Ground Truths
            (
                [
                    _create_detections(
                        xyxy=[[0, 0, 2, 2], [0, 4, 2, 6]],
                        class_id=[0, 0],
                        confidence=[0.9, 0.9],
                    )
                ],
                [Detections.empty()],
                [0, 1, 2],  # Class ids
                0.5,  # Confidence threshold
                0.5,  # IOU threshold
                np.array(
                    [
                        [0.0, 0.0, 0.0, 0.0],
                        [0.0, 0.0, 0.0, 0.0],
                        [0.0, 0.0, 0.0, 0.0],
                        [2.0, 0.0, 0.0, 0.0],  # 2 FP
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 14: Empty Detections
            (
                [Detections.empty()],
                [
                    _create_detections(
                        xyxy=[[0, 0, 2, 2], [0, 4, 2, 6]], class_id=[0, 0]
                    )
                ],
                [0, 1, 2],  # Class ids
                0.5,  # Confidence threshold
                0.5,  # IOU threshold
                np.array(
                    [
                        [0.0, 0.0, 0.0, 2.0],  # 2 TP
                        [0.0, 0.0, 0.0, 0.0],
                        [0.0, 0.0, 0.0, 0.0],
                        [0.0, 0.0, 0.0, 0.0],
                    ]
                ),
                DoesNotRaise(),
            ),
            # Test 15: Symmetric multi-class confusions with higher counts
            (
                [Detections.empty()],
                [Detections.empty()],
                [0, 1, 2],  # Class ids
                0.5,  # Confidence threshold
                0.5,  # IOU threshold
                np.array(
                    [
                        [0.0, 0.0, 0.0, 0.0],
                        [0.0, 0.0, 0.0, 0.0],
                        [0.0, 0.0, 0.0, 0.0],
                        [0.0, 0.0, 0.0, 0.0],
                    ]
                ),
                DoesNotRaise(),
            ),
        ],
    )
    def test_confusion_matrix(
        self,
        predictions,
        targets,
        classes,
        conf_threshold,
        iou_threshold,
        expected_result,
        exception: Exception,
    ) -> None:
        with exception:
            confusion_matrix = ConfusionMatrix.from_detections(
                predictions=predictions,
                targets=targets,
                classes=classes,
                conf_threshold=conf_threshold,
                iou_threshold=iou_threshold,
            )

        # Verify the confusion matrix matches expected
        # AssertionError if the two arrays are not equal
        np.testing.assert_array_equal(confusion_matrix.matrix, expected_result)

    def test_confusion_matrix_on_yolo_dataset(self, yolo_dataset_structure) -> None:
        """
        Test confusion matrix calculation on a YOLO-format dataset.

        This test verifies that the confusion matrix fix (considering both IoU AND
        class agreement) works correctly when applied to a dataset loaded from
        roboflow-format YOLO data. It creates a synthetic dataset with specific
        scenarios where predictions have high IoU but wrong class, ensuring only
        predictions with correct class are matched.
        """
        dataset_info = yolo_dataset_structure
        classes = ["dog", "cat", "person"]

        # Load dataset using supervision's YOLO loader
        dataset = DetectionDataset.from_yolo(
            images_directory_path=dataset_info["images_dir"],
            annotations_directory_path=dataset_info["labels_dir"],
            data_yaml_path=dataset_info["data_yaml_path"],
        )

        # Verify dataset loaded correctly
        assert len(dataset) == dataset_info["num_images"], (
            f"Dataset should have {dataset_info['num_images']} images, "
            f"but got {len(dataset)}. Dataset loading may have failed."
        )
        assert dataset.classes == classes, (
            f"Dataset classes should be {classes}, but got {dataset.classes}. "
            f"Check data.yaml parsing."
        )

        # Test confusion matrix with the dataset
        # Split the dataset to test split functionality
        train_dataset, test_dataset = dataset.split(
            split_ratio=0.5, random_state=42, shuffle=True
        )

        assert len(train_dataset) + len(test_dataset) == len(dataset), (
            f"Split datasets should sum to original dataset size ({len(dataset)}), "
            f"but got {len(train_dataset)} + {len(test_dataset)} = "
            f"{len(train_dataset) + len(test_dataset)}. Dataset split may be broken."
        )
        assert train_dataset.classes == classes, (
            "Train dataset should preserve class list after split"
        )
        assert test_dataset.classes == classes, (
            "Test dataset should preserve class list after split"
        )

        # Create predictions that test the IoU+class matching fix
        predictions = []
        targets = []

        for img_path, img, gt_detections in test_dataset:
            targets.append(gt_detections)
            predictions.append(
                create_predictions_with_class_iou_tests(gt_detections, len(classes))
            )

        # Calculate confusion matrix
        confusion_matrix = ConfusionMatrix.from_detections(
            predictions=predictions,
            targets=targets,
            classes=list(range(len(classes))),
            conf_threshold=0.5,
            iou_threshold=0.5,
        )

        # Verify confusion matrix structure and basic properties
        n = len(classes) + 1
        assert confusion_matrix.matrix.shape == (n, n), (
            f"Expected shape ({n}, {n}), got {confusion_matrix.matrix.shape}"
        )

        # Count TPs (diagonal) and total ground truths
        total_gt = sum(len(t) for t in targets if len(t) > 0)
        total_tp = sum(confusion_matrix.matrix[i, i] for i in range(len(classes)))

        assert total_tp > 0, (
            f"No TPs found (TP={total_tp}, GT={total_gt}), matching is broken"
        )

        # Count FPs (last column) - should include wrong-class predictions
        total_fp = confusion_matrix.matrix[: len(classes), -1].sum()
        assert total_fp >= 0, f"FP count negative ({total_fp}), computation bug"

        # Verify IoU+class fix: wrong-class preds should become FPs,
        # not match GTs
        assert total_fp > 0 or total_tp == total_gt, (
            f"Expected FPs from wrong-class preds (got {total_fp}) or all GTs "
            f"matched (TP={total_tp}, GT={total_gt}). IoU+class fix may be broken: "
            f"wrong-class preds with high IoU might incorrectly match GTs."
        )

    def test_confusion_matrix_benchmark_saves_validation_visualizations(
        self,
        tmp_path,
    ):
        image = np.zeros((32, 32, 3), dtype=np.uint8)
        targets = Detections(
            xyxy=np.array([[2, 2, 12, 12], [18, 18, 28, 28]], dtype=np.float32),
            class_id=np.array([0, 1]),
        )
        predictions = Detections(
            xyxy=np.array([[2, 2, 12, 12], [4, 18, 12, 28]], dtype=np.float32),
            confidence=np.array([0.95, 0.88]),
            class_id=np.array([0, 1]),
        )

        class Dataset:
            classes: ClassVar[list[str]] = ["cat", "dog"]

            def __iter__(self):
                yield "sample.jpg", image, targets

        def callback(_: np.ndarray) -> Detections:
            return predictions

        confusion_matrix = ConfusionMatrix.benchmark(
            dataset=Dataset(),
            callback=callback,
            save_directory_path=tmp_path,
        )

        saved_image_path = tmp_path / "sample.jpg"
        assert saved_image_path.exists()

        saved_image = cv2.imread(str(saved_image_path))
        assert saved_image is not None
        assert saved_image.shape[:2] == (64, 64)
        gt_panel = saved_image[:32, :32]
        tp_panel = saved_image[:32, 32:]
        fp_panel = saved_image[32:, :32]
        fn_panel = saved_image[32:, 32:]

        # Assert boxes are rendered in the expected panels, away from borders/dividers.
        assert np.any(gt_panel[2:13, 2:13] != 0)
        assert np.any(tp_panel[2:13, 2:13] != 0)
        # Rows 25-28 are below the panel title text; cols 4-9 are at the left edge
        # of the FP box [4,18,12,28] — non-zero only if the box is rendered.
        assert np.any(fp_panel[25:29, 4:9] != 0)
        # Rows 25-28 are below the panel title text; cols 18-23 are at the left
        # edge of the FN target box [18,18,28,28] — non-zero only if box rendered.
        assert np.any(fn_panel[25:29, 18:23] != 0)

        # Basic sanity that panels differ.
        assert not np.array_equal(gt_panel, tp_panel)
        assert not np.array_equal(gt_panel, fp_panel)
        assert not np.array_equal(gt_panel, fn_panel)
        assert not np.array_equal(tp_panel, fp_panel)
        assert not np.array_equal(tp_panel, fn_panel)
        assert not np.array_equal(fp_panel, fn_panel)

        assert confusion_matrix.matrix.shape == (3, 3)

    @pytest.mark.parametrize(
        ("predictions", "targets", "metric_target", "exception"),
        [
            (
                [np.zeros((1, 10), dtype=np.float32)],
                [np.zeros((1, 9), dtype=np.float32)],
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                DoesNotRaise(),
            ),
            (
                [np.zeros((1, 6), dtype=np.float32)],
                [np.zeros((1, 5), dtype=np.float32)],
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                pytest.raises(ValueError, match="Predictions must have shape"),
            ),
            (
                [np.zeros((1, 10), dtype=np.float32)],
                [np.zeros((1, 9), dtype=np.float32)],
                MetricTarget.BOXES,
                pytest.raises(ValueError, match="Predictions must have shape"),
            ),
            pytest.param(
                [np.zeros((1, 10), dtype=np.float32)],
                [np.zeros((1, 5), dtype=np.float32)],
                MetricTarget.ORIENTED_BOUNDING_BOXES,
                pytest.raises(ValueError, match="Targets must have shape"),
                id="obb-correct-pred-cols-wrong-target-cols",
            ),
        ],
    )
    def test_validate_input_tensors_obb(
        self, predictions, targets, metric_target, exception
    ):
        with exception:
            _validate_input_tensors(predictions, targets, metric_target=metric_target)

    def test_confusion_matrix_obb(self):
        """
        Verify OBB support in ConfusionMatrix.
        Test scenarios:
        1. Perfect OBB overlap (Rotation Match)
        2. Rotation Sensitivity (Same AABB, different rotation)
        3. Regression (BOXES mode)
        """
        classes = ["box"]

        # Perfect OBB overlap
        # 45 degree rotated box
        obb_coords = np.array(
            [[[5, 0], [10, 5], [5, 10], [0, 5]]], dtype=np.float32
        )  # Diamond shape
        gt = [
            Detections(
                xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                class_id=np.array([0]),
                data={"xyxyxyxy": obb_coords},
            )
        ]
        pred = [
            Detections(
                xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                class_id=np.array([0]),
                confidence=np.array([0.9]),
                data={"xyxyxyxy": obb_coords},
            )
        ]

        cm_obb = ConfusionMatrix.from_detections(
            predictions=pred,
            targets=gt,
            classes=classes,
            metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES,
        )
        # Expected TP = 1
        assert cm_obb.matrix[0, 0] == 1
        assert cm_obb.matrix.sum() == 1

        # Rotation Sensitivity
        # GT is 45 deg, Pred is axis-aligned box with same AABB
        aabb_coords = np.array([[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=np.float32)
        pred_aabb = [
            Detections(
                xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                class_id=np.array([0]),
                confidence=np.array([0.9]),
                data={"xyxyxyxy": aabb_coords},
            )
        ]

        # In OBB mode, IoU between diamond and square is 0.5
        cm_sensitivity_obb = ConfusionMatrix.from_detections(
            predictions=pred_aabb,
            targets=gt,
            classes=classes,
            iou_threshold=0.6,  # Threshold higher than 0.5
            metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES,
        )
        # Expected FN=1, FP=1 (no match because IoU=0.5 < 0.6)
        assert cm_sensitivity_obb.matrix[0, 1] == 1  # FN
        assert cm_sensitivity_obb.matrix[1, 0] == 1  # FP

        # In BOXES mode, they match perfectly
        cm_sensitivity_boxes = ConfusionMatrix.from_detections(
            predictions=pred_aabb,
            targets=gt,
            classes=classes,
            iou_threshold=0.6,
            metric_target=MetricTarget.BOXES,
        )
        # Expected TP = 1
        assert cm_sensitivity_boxes.matrix[0, 0] == 1

        # Deterministic comparison: OBB IoU should be less than AABB IoU
        # Here OBB IoU is 0.5, AABB IoU is 1.0
        # We can verify this by checking that a threshold between them
        # differentiates behavior
        assert cm_sensitivity_obb.matrix[0, 0] == 0
        assert cm_sensitivity_boxes.matrix[0, 0] == 1

    def test_confusion_matrix_obb_regression_1760(self):
        """Regression for #1760: thin OBBs with same AABB must not match.

        Two thin bars (100 px long, 10 px wide) at 45° and -45° share an identical
        AABB (AABB IoU = 1.0), but their actual OBB overlap is only at the crossing
        centre (OBB IoU ≈ 0.05).

        With iou_threshold=0.5:
        - ORIENTED_BOUNDING_BOXES mode: no match → FP + FN (bug before fix: TP)
        - BOXES mode: AABB IoU=1.0 → TP (controls: confirms AABB path unbroken)

        A regression that swaps oriented_box_iou_batch back to box_iou_batch would
        flip the OBB assertion to TP, surfacing the exact bug from issue #1760.
        """
        classes = ["bar"]
        sq2 = np.float32(1.0 / np.sqrt(2))
        cx, cy = np.float32(100.0), np.float32(100.0)
        hl, hw = np.float32(50.0), np.float32(5.0)

        # Bar at 45°: length direction (sq2, sq2), width direction (-sq2, sq2)
        bar_45 = np.array(
            [
                [cx + (hl - hw) * sq2, cy + (hl + hw) * sq2],
                [cx + (hl + hw) * sq2, cy + (hl - hw) * sq2],
                [cx - (hl - hw) * sq2, cy - (hl + hw) * sq2],
                [cx - (hl + hw) * sq2, cy - (hl - hw) * sq2],
            ],
            dtype=np.float32,
        )
        # Bar at -45°: length direction (sq2, -sq2), width direction (sq2, sq2)
        bar_neg45 = np.array(
            [
                [cx + (hl + hw) * sq2, cy - (hl - hw) * sq2],
                [cx + (hl - hw) * sq2, cy - (hl + hw) * sq2],
                [cx - (hl + hw) * sq2, cy + (hl - hw) * sq2],
                [cx - (hl - hw) * sq2, cy + (hl + hw) * sq2],
            ],
            dtype=np.float32,
        )

        # Both bars share the same axis-aligned bounding box
        half_aabb = (hl + hw) * sq2
        shared_xyxy = np.array(
            [[cx - half_aabb, cy - half_aabb, cx + half_aabb, cy + half_aabb]],
            dtype=np.float32,
        )

        gt = [
            Detections(
                xyxy=shared_xyxy,
                class_id=np.array([0]),
                data={"xyxyxyxy": bar_45[np.newaxis]},
            )
        ]
        pred = [
            Detections(
                xyxy=shared_xyxy,
                class_id=np.array([0]),
                confidence=np.array([0.9], dtype=np.float32),
                data={"xyxyxyxy": bar_neg45[np.newaxis]},
            )
        ]

        # OBB mode: orthogonal bars barely overlap → FP and FN, not TP
        cm_obb = ConfusionMatrix.from_detections(
            predictions=pred,
            targets=gt,
            classes=classes,
            iou_threshold=0.5,
            metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES,
        )
        assert cm_obb.matrix[0, 0] == 0  # no TP
        assert cm_obb.matrix[0, 1] == 1  # FN
        assert cm_obb.matrix[1, 0] == 1  # FP

        # BOXES mode: identical AABB → TP (controls that AABB path is intact)
        cm_boxes = ConfusionMatrix.from_detections(
            predictions=pred,
            targets=gt,
            classes=classes,
            iou_threshold=0.5,
            metric_target=MetricTarget.BOXES,
        )
        assert cm_boxes.matrix[0, 0] == 1
        assert cm_boxes.matrix.sum() == 1

    @pytest.mark.parametrize(
        ("pred_tensor", "target_tensor", "iou_threshold", "expected_matrix"),
        [
            pytest.param(
                np.array([[5, 0, 10, 5, 5, 10, 0, 5, 0, 0.9]], dtype=np.float32),
                np.array([[5, 0, 10, 5, 5, 10, 0, 5, 0]], dtype=np.float32),
                0.5,
                np.array([[1.0, 0.0], [0.0, 0.0]]),
                id="perfect_match_tp",
            ),
            pytest.param(
                np.array([[0, 0, 10, 0, 10, 10, 0, 10, 0, 0.9]], dtype=np.float32),
                np.array([[5, 0, 10, 5, 5, 10, 0, 5, 0]], dtype=np.float32),
                0.3,
                np.array([[1.0, 0.0], [0.0, 0.0]]),
                id="partial_obb_match_tp_at_threshold_0_3",
            ),
            pytest.param(
                np.array([[0, 0, 10, 0, 10, 10, 0, 10, 0, 0.9]], dtype=np.float32),
                np.array([[5, 0, 10, 5, 5, 10, 0, 5, 0]], dtype=np.float32),
                0.7,
                np.array([[0.0, 1.0], [1.0, 0.0]]),
                id="partial_obb_no_match_at_threshold_0_7",
            ),
        ],
    )
    def test_confusion_matrix_from_tensors_obb(
        self, pred_tensor, target_tensor, iou_threshold, expected_matrix
    ):
        """Direct from_tensors OBB end-to-end coverage with pre-built tensors."""
        cm = ConfusionMatrix.from_tensors(
            predictions=[pred_tensor],
            targets=[target_tensor],
            classes=["box"],
            iou_threshold=iou_threshold,
            metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES,
        )
        np.testing.assert_array_equal(cm.matrix, expected_matrix)

    @pytest.mark.parametrize(
        "call",
        [
            pytest.param(
                _call_confusion_matrix_from_detections_masks,
                id="from_detections",
            ),
            pytest.param(
                _call_confusion_matrix_from_tensors_masks,
                id="from_tensors",
            ),
            pytest.param(
                _call_confusion_matrix_evaluate_detection_batch_masks,
                id="evaluate_detection_batch",
            ),
        ],
    )
    def test_confusion_matrix_masks_rejection(self, call):
        """MetricTarget.MASKS raises ValueError at every public entry point."""
        with pytest.raises(ValueError, match=r"MetricTarget\.MASKS"):
            call()

    def test_confusion_matrix_multiclass_obb(self):
        """Multi-class OBB: TP for exact match, FP+FN for rotation mismatch."""
        classes = ["box", "circle"]

        # Ground truth: class-0 diamond at [0,0,10,10], class-1 diamond at [20,20,30,30]
        gt = [
            Detections(
                xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
                class_id=np.array([0, 1]),
                data={
                    "xyxyxyxy": np.array(
                        [
                            [[5, 0], [10, 5], [5, 10], [0, 5]],
                            [[25, 20], [30, 25], [25, 30], [20, 25]],
                        ],
                        dtype=np.float32,
                    )
                },
            )
        ]
        # Predictions: class-0 same diamond (OBB IoU=1.0 → TP);
        #              class-1 axis-aligned square (OBB IoU=0.5 < threshold=0.6 → FP+FN)
        pred = [
            Detections(
                xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
                class_id=np.array([0, 1]),
                confidence=np.array([0.9, 0.8], dtype=np.float32),
                data={
                    "xyxyxyxy": np.array(
                        [
                            [[5, 0], [10, 5], [5, 10], [0, 5]],
                            [[20, 20], [30, 20], [30, 30], [20, 30]],
                        ],
                        dtype=np.float32,
                    )
                },
            )
        ]

        cm = ConfusionMatrix.from_detections(
            predictions=pred,
            targets=gt,
            classes=classes,
            iou_threshold=0.6,
            metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES,
        )

        assert cm.matrix[0, 0] == 1  # TP class 0
        assert cm.matrix[1, 2] == 1  # FN class 1 (unmatched GT)
        assert cm.matrix[2, 1] == 1  # FP class 1 (unmatched pred)

    def test_confusion_matrix_metric_target_persistence_from_detections(self):
        """metric_target field reflects the value passed to from_detections."""
        xyxy = np.array([[0, 0, 10, 10]], dtype=np.float32)
        cm = ConfusionMatrix.from_detections(
            predictions=[
                Detections(
                    xyxy=xyxy, class_id=np.array([0]), confidence=np.array([0.9])
                )
            ],
            targets=[Detections(xyxy=xyxy, class_id=np.array([0]))],
            classes=["box"],
            metric_target=MetricTarget.BOXES,
        )
        assert cm.metric_target == MetricTarget.BOXES

    def test_confusion_matrix_metric_target_persistence_from_tensors(self):
        """metric_target field reflects the value passed to from_tensors."""
        cm = ConfusionMatrix.from_tensors(
            predictions=[np.array([[0, 0, 10, 10, 0, 0.9]], dtype=np.float32)],
            targets=[np.array([[0, 0, 10, 10, 0]], dtype=np.float32)],
            classes=["box"],
            metric_target=MetricTarget.BOXES,
        )
        assert cm.metric_target == MetricTarget.BOXES

    def test_greedy_matching_two_valid_pairs(self):
        """Greedy matching finds both TPs; np.unique style missed the second pair."""
        preds = Detections(
            xyxy=np.array([[40, 60, 380, 470], [108, 60, 448, 470]], dtype=np.float32),
            confidence=np.array([0.95, 0.90]),
            class_id=np.array([0, 0]),
        )
        targets = Detections(
            xyxy=np.array([[40, 60, 380, 470], [210, 60, 550, 470]], dtype=np.float32),
            class_id=np.array([0, 0]),
        )

        result = MeanAveragePrecision.from_detections(
            predictions=[preds], targets=[targets]
        )

        assert result.map50 == pytest.approx(1.0, abs=0.01)


class TestMeanAveragePrecisionBackgroundFalsePositives:
    """MeanAveragePrecision.from_tensors penalizes predictions on GT-empty images."""

    def test_background_false_positives_lower_map(self) -> None:
        """False positives on a GT-empty image drop map50 below the FP-free baseline."""
        # Arrange
        matched_target = np.array([[0.0, 0.0, 10.0, 10.0, 0]], dtype=np.float32)
        matched_prediction = np.array(
            [[0.0, 0.0, 10.0, 10.0, 0, 0.9]], dtype=np.float32
        )
        background_target = np.zeros((0, 5), dtype=np.float32)
        background_predictions = np.array(
            [
                [100.0, 100.0, 110.0, 110.0, 0, 0.95],
                [200.0, 200.0, 210.0, 210.0, 0, 0.95],
                [300.0, 300.0, 310.0, 310.0, 0, 0.95],
            ],
            dtype=np.float32,
        )

        # Act
        without_fp = MeanAveragePrecision.from_tensors(
            predictions=[matched_prediction],
            targets=[matched_target],
        )
        with_fp = MeanAveragePrecision.from_tensors(
            predictions=[matched_prediction, background_predictions],
            targets=[matched_target, background_target],
        )

        # Assert
        assert without_fp.map50 == pytest.approx(1.0, abs=0.01)
        assert with_fp.map50 < without_fp.map50
        assert with_fp.map50 < 0.5
        assert with_fp.map75 < without_fp.map75
        assert with_fp.map50_95 < without_fp.map50_95

    def test_ground_truth_present_path_uses_coco_101_point_ap(self) -> None:
        """GT-present scenario uses the corrected COCO 101-point AP value."""
        # Arrange
        targets = [
            np.array(
                [
                    [0.0, 0.0, 3.0, 3.0, 0],
                    [2.0, 2.0, 5.0, 5.0, 0],
                    [6.0, 1.0, 8.0, 3.0, 1],
                ],
                dtype=np.float32,
            )
        ]
        predictions = [
            np.array(
                [
                    [0.0, 0.0, 3.0, 3.0, 0, 0.9],
                    [0.1, 0.1, 3.0, 3.0, 0, 0.9],
                    [6.0, 1.0, 8.0, 3.0, 1, 0.8],
                ],
                dtype=np.float32,
            )
        ]

        # Act
        result = MeanAveragePrecision.from_tensors(
            predictions=predictions, targets=targets
        )

        # Assert
        assert result.map50 == pytest.approx(0.7524752475, abs=1e-9)

    def test_all_background_predictions_return_zero_not_nan(self) -> None:
        """All-background dataset with predictions must yield 0.0 mAP, not NaN."""
        # Arrange — no GT objects anywhere; model still fires predictions
        background_target = np.zeros((0, 5), dtype=np.float32)
        background_predictions = np.array(
            [[0.0, 0.0, 10.0, 10.0, 0, 0.9]], dtype=np.float32
        )

        # Act
        result = MeanAveragePrecision.from_tensors(
            predictions=[background_predictions],
            targets=[background_target],
        )

        # Assert — must be finite 0.0, not NaN (regression for all-background datasets)
        assert result.map50 == pytest.approx(0.0)
        assert result.map75 == pytest.approx(0.0)
        assert result.map50_95 == pytest.approx(0.0)


class TestSplitDetectionsByOutcome:
    """Tests for _split_detections_by_outcome matching and filtering logic."""

    def test_confidence_none_all_survive(self):
        """Predictions with no confidence score pass threshold regardless."""
        predictions = Detections(
            xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
            confidence=None,
            class_id=np.array([0]),
        )
        targets = Detections(
            xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
            class_id=np.array([0]),
        )

        tp, fp, fn = _split_detections_by_outcome(predictions, targets, 0.5, 0.5)

        assert len(tp) == 1
        assert len(fp) == 0
        assert len(fn) == 0

    def test_all_below_threshold_returns_zero_tp(self):
        """Predictions below conf_threshold are excluded, leaving only FN."""
        predictions = Detections(
            xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
            confidence=np.array([0.2]),
            class_id=np.array([0]),
        )
        targets = Detections(
            xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
            class_id=np.array([0]),
        )

        tp, fp, fn = _split_detections_by_outcome(predictions, targets, 0.5, 0.5)

        assert len(tp) == 0
        assert len(fp) == 0
        assert len(fn) == 1

    def test_cross_class_spatial_match(self):
        """Spatially overlapping but class-mismatched pair → FP + FN."""
        predictions = Detections(
            xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
        )
        targets = Detections(
            xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
            class_id=np.array([1]),
        )

        tp, fp, fn = _split_detections_by_outcome(predictions, targets, 0.5, 0.0)

        assert len(tp) == 0
        assert len(fp) == 1
        assert len(fn) == 1

    def test_empty_predictions_all_fn(self):
        """Zero predictions → every target becomes a false negative."""
        predictions = Detections.empty()
        predictions.class_id = np.array([], dtype=np.int64)
        targets = Detections(
            xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
            class_id=np.array([0, 1]),
        )

        tp, fp, fn = _split_detections_by_outcome(predictions, targets, 0.5, 0.5)

        assert len(tp) == 0
        assert len(fp) == 0
        assert len(fn) == 2

    def test_empty_targets_all_fp(self):
        """Zero targets → every prediction becomes a false positive."""
        predictions = Detections(
            xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
            confidence=np.array([0.9]),
            class_id=np.array([0]),
        )
        targets = Detections.empty()
        targets.class_id = np.array([], dtype=np.int64)

        tp, fp, fn = _split_detections_by_outcome(predictions, targets, 0.5, 0.5)

        assert len(tp) == 0
        assert len(fp) == 1
        assert len(fn) == 0

    @pytest.mark.parametrize(
        ("predictions", "targets"),
        [
            pytest.param(
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=None,
                ),
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=np.array([0]),
                ),
                id="predictions-class-id-none",
            ),
            pytest.param(
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=np.array([0]),
                ),
                Detections(
                    xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
                    class_id=None,
                ),
                id="targets-class-id-none",
            ),
        ],
    )
    def test_class_id_none_raises_value_error(self, predictions, targets):
        """Missing class_id on either input raises ValueError."""
        with pytest.raises(ValueError, match="class_id"):
            _split_detections_by_outcome(predictions, targets, 0.5, 0.5)


class TestConfusionMatrixPlot:
    """Tests for ConfusionMatrix.plot rendering."""

    @pytest.mark.parametrize(
        "normalize",
        [
            pytest.param(False, id="raw-counts"),
            pytest.param(True, id="normalized"),
        ],
    )
    def test_plot_returns_figure(self, normalize: bool) -> None:
        """plot() must not crash on the integer matrix produced by from_tensors."""
        targets = [
            np.array(
                [
                    [0.0, 0.0, 3.0, 3.0, 0],
                    [6.0, 1.0, 8.0, 3.0, 1],
                ],
                dtype=np.float32,
            )
        ]
        predictions = [
            np.array(
                [
                    [0.0, 0.0, 3.0, 3.0, 0, 0.9],
                ],
                dtype=np.float32,
            )
        ]
        confusion_matrix = ConfusionMatrix.from_tensors(
            predictions=predictions,
            targets=targets,
            classes=["person", "dog"],
        )

        fig = confusion_matrix.plot(normalize=normalize)

        assert fig is not None
        plt.close(fig)
