from contextlib import ExitStack as DoesNotRaise

import numpy as np
import numpy.typing as npt
import pytest

from supervision.detection.utils.converters import (
    _base48_decode,
    _base48_encode,
    _delta_decode,
    _delta_encode,
    _mask_to_rle_counts,
    _rle_counts_to_mask,
    is_compressed_rle,
    mask_to_polygons,
    mask_to_rle,
    mask_to_xyxy,
    polygon_to_mask,
    polygon_to_xyxy,
    rle_to_mask,
    xcycwh_to_xyxy,
    xywh_to_xyxy,
    xyxy_to_mask,
    xyxy_to_polygons,
    xyxy_to_xcycarh,
    xyxy_to_xywh,
)


@pytest.mark.parametrize(
    ("xywh", "expected_result"),
    [
        (np.array([[10, 20, 30, 40]]), np.array([[10, 20, 40, 60]])),  # standard case
        (np.array([[0, 0, 0, 0]]), np.array([[0, 0, 0, 0]])),  # zero size bounding box
        (
            np.array([[50, 50, 100, 100]]),
            np.array([[50, 50, 150, 150]]),
        ),  # large bounding box
        (
            np.array([[-10, -20, 30, 40]]),
            np.array([[-10, -20, 20, 20]]),
        ),  # negative coordinates
        (np.array([[50, 50, 0, 30]]), np.array([[50, 50, 50, 80]])),  # zero width
        (np.array([[50, 50, 20, 0]]), np.array([[50, 50, 70, 50]])),  # zero height
        (np.array([]).reshape(0, 4), np.array([]).reshape(0, 4)),  # empty array
    ],
)
def test_xywh_to_xyxy(xywh: np.ndarray, expected_result: np.ndarray) -> None:
    result = xywh_to_xyxy(xywh)
    np.testing.assert_array_equal(result, expected_result)


@pytest.mark.parametrize(
    ("xyxy", "expected_result"),
    [
        (np.array([[10, 20, 40, 60]]), np.array([[10, 20, 30, 40]])),  # standard case
        (np.array([[0, 0, 0, 0]]), np.array([[0, 0, 0, 0]])),  # zero size bounding box
        (
            np.array([[50, 50, 150, 150]]),
            np.array([[50, 50, 100, 100]]),
        ),  # large bounding box
        (
            np.array([[-10, -20, 20, 20]]),
            np.array([[-10, -20, 30, 40]]),
        ),  # negative coordinates
        (np.array([[50, 50, 50, 80]]), np.array([[50, 50, 0, 30]])),  # zero width
        (np.array([[50, 50, 70, 50]]), np.array([[50, 50, 20, 0]])),  # zero height
        (np.array([]).reshape(0, 4), np.array([]).reshape(0, 4)),  # empty array
    ],
)
def test_xyxy_to_xywh(xyxy: np.ndarray, expected_result: np.ndarray) -> None:
    result = xyxy_to_xywh(xyxy)
    np.testing.assert_array_equal(result, expected_result)


@pytest.mark.parametrize(
    ("xyxy", "expected_result"),
    [
        # Empty and zero cases
        (np.array([]).reshape(0, 4), np.array([]).reshape(0, 4)),  # empty array
        (
            np.array([[0, 0, 0, 0]]),
            np.array([[0, 0, 0.0, 0]]),
        ),  # zero size bounding box
        (
            np.array([[10, 10, 10, 10]]),
            np.array([[10, 10, 0.0, 0]]),
        ),  # point (x1=x2, y1=y2)
        # Zero width/height cases
        (np.array([[50, 50, 80, 50]]), np.array([[65, 50, 0.0, 0]])),  # zero height
        (np.array([[50, 50, 50, 80]]), np.array([[50, 65, 0.0, 30]])),  # zero width
        # Standard cases
        (np.array([[10, 20, 40, 60]]), np.array([[25, 40, 0.75, 40]])),  # standard case
        (
            np.array([[-30, -40, -10, -20]]),
            np.array([[-20, -30, 1.0, 20]]),
        ),  # all negative values
        (
            np.array([[0.1, 0.2, 0.4, 0.6]]),
            np.array([[0.25, 0.4, 0.75, 0.4]]),
        ),  # values between 0-1
        # Different aspect ratios
        (
            np.array([[10, 20, 50, 100]]),
            np.array([[30, 60, 0.5, 80]]),
        ),  # tall rectangle (height > width)
        (
            np.array([[20, 10, 100, 50]]),
            np.array([[60, 30, 2.0, 40]]),
        ),  # wide rectangle (width > height)
        (
            np.array([[50, 50, 150, 150]]),
            np.array([[100, 100, 1.0, 100]]),
        ),  # height == width
        # Multiple boxes in one array
        (
            np.array([[0, 0, 0, 0], [10, 20, 40, 60]]),
            np.array([[0, 0, 0.0, 0], [25, 40, 0.75, 40]]),
        ),  # one zero-sized box and one normal box
    ],
)
def test_xyxy_to_xcycarh(xyxy: np.ndarray, expected_result: np.ndarray) -> None:
    result = xyxy_to_xcycarh(xyxy)
    np.testing.assert_allclose(result, expected_result)


@pytest.mark.parametrize(
    ("xcycwh", "expected_result"),
    [
        (np.array([[50, 50, 20, 30]]), np.array([[40, 35, 60, 65]])),  # standard case
        (np.array([[0, 0, 0, 0]]), np.array([[0, 0, 0, 0]])),  # zero size bounding box
        (
            np.array([[50, 50, 100, 100]]),
            np.array([[0, 0, 100, 100]]),
        ),  # large bounding box centered at (50, 50)
        (
            np.array([[-10, -10, 20, 30]]),
            np.array([[-20, -25, 0, 5]]),
        ),  # negative coordinates
        (np.array([[50, 50, 0, 30]]), np.array([[50, 35, 50, 65]])),  # zero width
        (np.array([[50, 50, 20, 0]]), np.array([[40, 50, 60, 50]])),  # zero height
        (np.array([]).reshape(0, 4), np.array([]).reshape(0, 4)),  # empty array
    ],
)
def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None:
    result = xcycwh_to_xyxy(xcycwh)
    np.testing.assert_array_equal(result, expected_result)


@pytest.mark.parametrize(
    ("boxes", "resolution_wh", "expected"),
    [
        # 0) Empty input
        (
            np.array([], dtype=float).reshape(0, 4),
            (5, 4),
            np.array([], dtype=bool).reshape(0, 4, 5),
        ),
        # 1) Single pixel box
        (
            np.array([[2, 1, 2, 1]], dtype=float),
            (5, 4),
            np.array(
                [
                    [
                        [False, False, False, False, False],
                        [False, False, True, False, False],
                        [False, False, False, False, False],
                        [False, False, False, False, False],
                    ]
                ],
                dtype=bool,
            ),
        ),
        # 2) Horizontal line, inclusive bounds
        (
            np.array([[1, 2, 3, 2]], dtype=float),
            (5, 4),
            np.array(
                [
                    [
                        [False, False, False, False, False],
                        [False, False, False, False, False],
                        [False, True, True, True, False],
                        [False, False, False, False, False],
                    ]
                ],
                dtype=bool,
            ),
        ),
        # 3) Vertical line, inclusive bounds
        (
            np.array([[3, 0, 3, 2]], dtype=float),
            (5, 4),
            np.array(
                [
                    [
                        [False, False, False, True, False],
                        [False, False, False, True, False],
                        [False, False, False, True, False],
                        [False, False, False, False, False],
                    ]
                ],
                dtype=bool,
            ),
        ),
        # 4) Proper rectangle fill
        (
            np.array([[1, 1, 3, 2]], dtype=float),
            (5, 4),
            np.array(
                [
                    [
                        [False, False, False, False, False],
                        [False, True, True, True, False],
                        [False, True, True, True, False],
                        [False, False, False, False, False],
                    ]
                ],
                dtype=bool,
            ),
        ),
        # 5) Negative coordinates clipped to [0, 0]
        (
            np.array([[-2, -1, 1, 1]], dtype=float),
            (5, 4),
            np.array(
                [
                    [
                        [True, True, False, False, False],
                        [True, True, False, False, False],
                        [False, False, False, False, False],
                        [False, False, False, False, False],
                    ]
                ],
                dtype=bool,
            ),
        ),
        # 6) Overflow coordinates clipped to width-1 and height-1
        (
            np.array([[3, 2, 10, 10]], dtype=float),
            (5, 4),
            np.array(
                [
                    [
                        [False, False, False, False, False],
                        [False, False, False, False, False],
                        [False, False, False, True, True],
                        [False, False, False, True, True],
                    ]
                ],
                dtype=bool,
            ),
        ),
        # 7) Invalid box where max < min after ints, mask stays empty
        (
            np.array([[3, 2, 1, 4]], dtype=float),
            (5, 4),
            np.array(
                [
                    [
                        [False, False, False, False, False],
                        [False, False, False, False, False],
                        [False, False, False, False, False],
                        [False, False, False, False, False],
                    ]
                ],
                dtype=bool,
            ),
        ),
        # 8) Fractional coordinates are floored by int conversion
        #    (0.2,0.2)-(2.8,1.9) -> (0,0)-(2,1)
        (
            np.array([[0.2, 0.2, 2.8, 1.9]], dtype=float),
            (5, 4),
            np.array(
                [
                    [
                        [True, True, True, False, False],
                        [True, True, True, False, False],
                        [False, False, False, False, False],
                        [False, False, False, False, False],
                    ]
                ],
                dtype=bool,
            ),
        ),
        # 9) Multiple boxes, separate masks
        (
            np.array([[0, 0, 1, 0], [2, 1, 4, 3]], dtype=float),
            (5, 4),
            np.array(
                [
                    # Box 0: row 0, cols 0..1
                    [
                        [True, True, False, False, False],
                        [False, False, False, False, False],
                        [False, False, False, False, False],
                        [False, False, False, False, False],
                    ],
                    # Box 1: rows 1..3, cols 2..4
                    [
                        [False, False, False, False, False],
                        [False, False, True, True, True],
                        [False, False, True, True, True],
                        [False, False, True, True, True],
                    ],
                ],
                dtype=bool,
            ),
        ),
    ],
)
def test_xyxy_to_mask(boxes: np.ndarray, resolution_wh, expected: np.ndarray) -> None:
    result = xyxy_to_mask(boxes, resolution_wh)
    assert result.dtype == np.bool_
    assert result.shape == expected.shape
    np.testing.assert_array_equal(result, expected)


def _mask_to_xyxy_reference(
    masks: np.ndarray, coordinate_convention: str = "inclusive"
) -> np.ndarray:
    """Per-mask `np.where` loop used as a ground-truth oracle."""
    xyxy = np.zeros((masks.shape[0], 4), dtype=int)
    for i, mask in enumerate(masks):
        rows, cols = np.where(mask)
        if len(rows) > 0 and len(cols) > 0:
            if coordinate_convention == "exclusive":
                x_max = int(cols.max()) + 1
                y_max = int(rows.max()) + 1
            else:
                x_max = int(cols.max())
                y_max = int(rows.max())
            xyxy[i, :] = [
                int(cols.min()),
                int(rows.min()),
                x_max,
                y_max,
            ]
    return xyxy


class TestMaskToXyxy:
    @pytest.mark.parametrize(
        ("masks", "expected"),
        [
            pytest.param(
                np.zeros((0, 5, 5), dtype=bool),
                np.zeros((0, 4), dtype=int),
                id="no-masks",
            ),
            pytest.param(
                np.zeros((2, 5, 5), dtype=bool),
                np.zeros((2, 4), dtype=int),
                id="all-empty-masks",
            ),
            pytest.param(
                np.array([[[False, False], [False, True]]], dtype=bool),
                np.array([[1, 1, 1, 1]], dtype=int),
                id="single-pixel-bottom-right",
            ),
            pytest.param(
                np.array([[[True, False], [False, False]]], dtype=bool),
                np.array([[0, 0, 0, 0]], dtype=int),
                id="single-pixel-top-left",
            ),
            pytest.param(
                np.array([[[False, True], [False, False]]], dtype=bool),
                np.array([[1, 0, 1, 0]], dtype=int),
                id="single-pixel-top-right",
            ),
            pytest.param(
                np.array([[[False, False], [True, False]]], dtype=bool),
                np.array([[0, 1, 0, 1]], dtype=int),
                id="single-pixel-bottom-left",
            ),
            pytest.param(
                np.ones((1, 4, 6), dtype=bool),
                np.array([[0, 0, 5, 3]], dtype=int),
                id="full-mask",
            ),
        ],
    )
    def test_mask_to_xyxy_known_values(
        self, masks: np.ndarray, expected: np.ndarray
    ) -> None:
        """Known boxes for empty, single-pixel, and full masks."""
        result = mask_to_xyxy(masks)

        assert result.dtype == expected.dtype
        assert result.shape == expected.shape
        np.testing.assert_array_equal(result, expected)

    @pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
    def test_mask_to_xyxy_matches_reference(self, seed: int) -> None:
        """Vectorized output is bit-identical to the per-mask scalar loop."""
        rng = np.random.default_rng(seed)
        masks = rng.random((6, 30, 40)) < 0.1
        masks[0] = False  # include an empty mask in the batch

        result = mask_to_xyxy(masks)
        reference = _mask_to_xyxy_reference(masks)

        assert result.dtype == reference.dtype
        np.testing.assert_array_equal(result, reference)

    def test_mask_to_xyxy_exclusive_matches_reference(self) -> None:
        """Exclusive bounds should return one-past-the-end coordinates."""
        masks = np.array(
            [
                [[False, False], [False, True]],
                [[True, True], [True, True]],
            ],
            dtype=bool,
        )

        result = mask_to_xyxy(masks, coordinate_convention="exclusive")
        reference = _mask_to_xyxy_reference(masks, coordinate_convention="exclusive")

        np.testing.assert_array_equal(result, reference)

    def test_xyxy_to_mask_exclusive_round_trip(self) -> None:
        """Exclusive boxes should round-trip through `xyxy_to_mask`."""
        boxes = np.array([[1, 1, 3, 3], [0, 0, 2, 1]], dtype=float)

        result = xyxy_to_mask(
            boxes=boxes,
            resolution_wh=(4, 4),
            coordinate_convention="exclusive",
        )

        expected = np.array(
            [
                [
                    [False, False, False, False],
                    [False, True, True, False],
                    [False, True, True, False],
                    [False, False, False, False],
                ],
                [
                    [True, True, False, False],
                    [False, False, False, False],
                    [False, False, False, False],
                    [False, False, False, False],
                ],
            ],
            dtype=bool,
        )

        assert result.dtype == np.bool_
        np.testing.assert_array_equal(result, expected)


@pytest.mark.parametrize(
    ("mask", "compressed", "expected_rle", "exception"),
    [
        (
            np.zeros((3, 3)).astype(bool),
            False,
            [9],
            DoesNotRaise(),
        ),  # mask with background only (mask with only False values)
        (
            np.ones((3, 3)).astype(bool),
            False,
            [0, 9],
            DoesNotRaise(),
        ),  # mask with foreground only (mask with only True values)
        (
            np.array(
                [
                    [0, 0, 0, 0, 0],
                    [0, 1, 1, 1, 0],
                    [0, 1, 0, 1, 0],
                    [0, 1, 1, 1, 0],
                    [0, 0, 0, 0, 0],
                ]
            ).astype(bool),
            False,
            [6, 3, 2, 1, 1, 1, 2, 3, 6],
            DoesNotRaise(),
        ),  # mask where foreground object has hole
        (
            np.array(
                [
                    [1, 0, 1, 0, 1],
                    [1, 0, 1, 0, 1],
                    [1, 0, 1, 0, 1],
                    [1, 0, 1, 0, 1],
                    [1, 0, 1, 0, 1],
                ]
            ).astype(bool),
            False,
            [0, 5, 5, 5, 5, 5],
            DoesNotRaise(),
        ),  # mask where foreground consists of 3 separate components
        (
            np.array(
                [
                    [False, False, False, False],
                    [False, True, True, False],
                    [False, True, True, False],
                    [False, False, False, False],
                ]
            ),
            True,
            "52203",
            DoesNotRaise(),
        ),  # compressed RLE string
        (
            np.array([[[]]]).astype(bool),
            False,
            None,
            pytest.raises(ValueError, match="Input mask must be 2D"),
        ),  # raises ValueError because mask dimensionality is not 2D
        (
            np.array([[]]).astype(bool),
            False,
            None,
            pytest.raises(ValueError, match="Input mask cannot be empty"),
        ),  # raises ValueError because mask is empty
    ],
)
def test_mask_to_rle(
    mask: npt.NDArray[np.bool_],
    compressed: bool,
    expected_rle: list[int] | str | None,
    exception: Exception,
) -> None:
    with exception:
        result = mask_to_rle(mask=mask, compressed=compressed)
        assert result == expected_rle


@pytest.mark.parametrize(
    ("rle", "resolution_wh", "expected_mask", "exception"),
    [
        (
            np.array([9]),
            [3, 3],
            np.zeros((3, 3)).astype(bool),
            DoesNotRaise(),
        ),  # mask with background only (mask with only False values); rle as array
        (
            [9],
            [3, 3],
            np.zeros((3, 3)).astype(bool),
            DoesNotRaise(),
        ),  # mask with background only (mask with only False values); rle as list
        (
            np.array([0, 9]),
            [3, 3],
            np.ones((3, 3)).astype(bool),
            DoesNotRaise(),
        ),  # mask with foreground only (mask with only True values)
        (
            np.array([6, 3, 2, 1, 1, 1, 2, 3, 6]),
            [5, 5],
            np.array(
                [
                    [0, 0, 0, 0, 0],
                    [0, 1, 1, 1, 0],
                    [0, 1, 0, 1, 0],
                    [0, 1, 1, 1, 0],
                    [0, 0, 0, 0, 0],
                ]
            ).astype(bool),
            DoesNotRaise(),
        ),  # mask where foreground object has hole
        (
            np.array([0, 5, 5, 5, 5, 5]),
            [5, 5],
            np.array(
                [
                    [1, 0, 1, 0, 1],
                    [1, 0, 1, 0, 1],
                    [1, 0, 1, 0, 1],
                    [1, 0, 1, 0, 1],
                    [1, 0, 1, 0, 1],
                ]
            ).astype(bool),
            DoesNotRaise(),
        ),  # mask where foreground consists of 3 separate components
        (
            np.array([0, 5, 5, 5, 5, 5]),
            [2, 2],
            None,
            pytest.raises(ValueError, match="sum of the number of pixels in the RLE"),
        ),  # raises ValueError because number of pixels in RLE does not match
        # number of pixels in expected mask (width x height).
        (
            b"3124OM1",
            [4, 4],
            np.array(
                [
                    [0, 0, 1, 1],
                    [0, 0, 1, 1],
                    [0, 1, 1, 0],
                    [1, 1, 0, 0],
                ]
            ).astype(bool),
            DoesNotRaise(),
        ),  # compressed RLE bytes
        (
            "52203",
            [4, 4],
            np.array(
                [
                    [0, 0, 0, 0],
                    [0, 1, 1, 0],
                    [0, 1, 1, 0],
                    [0, 0, 0, 0],
                ]
            ).astype(bool),
            DoesNotRaise(),
        ),  # compressed RLE string
        (
            "!",
            [4, 4],
            None,
            pytest.raises(ValueError, match="Malformed compressed RLE string"),
        ),  # malformed compressed RLE string with invalid character
        (
            "52P",
            [4, 4],
            None,
            pytest.raises(ValueError, match="Malformed compressed RLE string"),
        ),  # malformed compressed RLE: unterminated continuation byte
        (
            b"\xff\xfe",
            [4, 4],
            None,
            pytest.raises(UnicodeDecodeError),
        ),  # bytes with invalid UTF-8 sequence raises UnicodeDecodeError
    ],
)
def test_rle_to_mask(
    rle: npt.NDArray[np.int_],
    resolution_wh: tuple[int, int],
    expected_mask: npt.NDArray[np.bool_],
    exception: Exception,
) -> None:
    with exception:
        result = rle_to_mask(rle=rle, resolution_wh=resolution_wh)
        assert np.all(result == expected_mask)


def test_mask_rle_compressed_round_trip() -> None:
    mask = np.array(
        [
            [False, False, False, False],
            [False, True, True, False],
            [False, True, True, False],
            [False, False, False, False],
        ]
    )
    compressed = mask_to_rle(mask, compressed=True)
    recovered = rle_to_mask(compressed, (4, 4))
    np.testing.assert_array_equal(mask, recovered)


# ---------------------------------------------------------------------------
# is_compressed_rle
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("rle", "expected"),
    [
        ("52203", True),  # str is compressed
        (b"52203", True),  # bytes is compressed
        ("", True),  # empty str still str
        (b"", True),  # empty bytes still bytes
        ([5, 2, 2, 2, 5], False),  # list is not compressed
        (np.array([5, 2, 2, 2, 5]), False),  # ndarray is not compressed
        (42, False),  # int is not compressed
        (None, False),  # None is not compressed
    ],
)
def test_is_compressed_rle(rle: object, expected: bool) -> None:
    """is_compressed_rle returns True for str/bytes, False otherwise."""
    assert is_compressed_rle(rle) == expected


# ---------------------------------------------------------------------------
# _base48_decode / _base48_encode
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("s", "expected"),
    [
        ("", []),  # empty string
        ("0", [0]),  # single zero
        ("5", [5]),  # single small value
        ("52203", [5, 2, 2, 0, 3]),  # raw delta values (NOT absolute counts)
        ("09", [0, 9]),  # two values, no continuation needed
    ],
)
def test_base48_decode(s: str, expected: list[int]) -> None:
    """_base48_decode returns raw delta-encoded integers from base-48 string."""
    assert _base48_decode(s) == expected


@pytest.mark.parametrize(
    "s",
    [
        "!",  # ord('!')-48 triggers continuation, string ends immediately
        "52P",  # 'P' sets continuation bit but string ends
    ],
)
def test_base48_decode_malformed(s: str) -> None:
    """_base48_decode raises ValueError on truncated continuation sequences."""
    with pytest.raises(ValueError, match="Malformed compressed RLE string"):
        _base48_decode(s)


@pytest.mark.parametrize(
    ("values", "expected"),
    [
        ([], ""),  # empty list
        ([0], "0"),  # single zero
        ([5], "5"),  # single small value
        ([5, 2, 2, 0, 3], "52203"),  # raw deltas encode to known string
        ([0, 9], "09"),  # two values
    ],
)
def test_base48_encode(values: list[int], expected: str) -> None:
    """_base48_encode converts raw delta integers to base-48 string."""
    assert _base48_encode(values) == expected


@pytest.mark.parametrize(
    "values",
    [
        [],
        [5, 2, 2, 0, 3],
        [0, 9],
        [6, 3, 2, 1, 1, 1, 2, 3, 6],
        [100],  # value >= 32 requires multi-byte continuation characters
        [1000],  # value requiring 3 continuation bytes
        [-3],  # negative delta: sign bit at bit 4 of final character
        [-1, 0, -100],  # multiple negative values
    ],
)
def test_base48_round_trip(values: list[int]) -> None:
    """_base48_decode(_base48_encode(v)) == v for any valid delta list."""
    assert _base48_decode(_base48_encode(values)) == values


# ---------------------------------------------------------------------------
# _delta_decode / _delta_encode
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("values", "expected"),
    [
        ([], []),  # empty
        ([5], [5]),  # single element unchanged
        ([5, 2], [5, 2]),  # two elements unchanged
        ([5, 2, 2], [5, 2, 2]),  # three elements unchanged
        ([5, 2, 2, 0, 3], [5, 2, 2, 2, 5]),  # delta applied from index 3
        ([0, 9], [0, 9]),  # two elements, no delta needed
        ([0, 16], [0, 16]),  # two elements, larger values
    ],
)
def test_delta_decode(values: list[int], expected: list[int]) -> None:
    """_delta_decode undoes COCO delta: counts[i] += counts[i-2] for i > 2."""
    assert _delta_decode(values) == expected


@pytest.mark.parametrize(
    ("counts", "expected"),
    [
        ([], []),  # empty
        ([5], [5]),  # single element unchanged
        ([5, 2], [5, 2]),  # two elements unchanged
        ([5, 2, 2], [5, 2, 2]),  # three elements unchanged
        ([5, 2, 2, 2, 5], [5, 2, 2, 0, 3]),  # delta applied from index 3
        ([0, 9], [0, 9]),  # two elements, no delta needed
    ],
)
def test_delta_encode(counts: list[int], expected: list[int]) -> None:
    """_delta_encode applies COCO delta: d[i] = counts[i] - counts[i-2] for i > 2."""
    assert _delta_encode(counts) == expected


@pytest.mark.parametrize(
    "counts",
    [
        [5, 2, 2, 2, 5],
        [0, 9],
        [6, 3, 2, 1, 1, 1, 2, 3, 6],
        [0, 5, 5, 5, 5, 5],
    ],
)
def test_delta_round_trip(counts: list[int]) -> None:
    """_delta_decode(_delta_encode(counts)) == counts for any count list."""
    assert _delta_decode(_delta_encode(counts)) == counts


# ---------------------------------------------------------------------------
# _mask_to_rle_counts / _rle_counts_to_mask
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("mask_2d", "expected_counts"),
    [
        (
            np.zeros((3, 3), dtype=bool),
            [9],
        ),  # all-False: one run of 9 background pixels
        (
            np.ones((3, 3), dtype=bool),
            [0, 9],
        ),  # all-True: 0 background then 9 foreground
        (
            np.array([[False, True], [True, False]]),
            [1, 2, 1],
        ),  # F-order [F,T,T,F] → starts with False, no leading-zero prepend
        (
            np.array([[True, False], [False, True]]),
            [0, 1, 2, 1],
        ),  # F-order [T,F,F,T] → starts with True, prepend 0
        (
            np.array(
                [
                    [False, False, False, False],
                    [False, True, True, False],
                    [False, True, True, False],
                    [False, False, False, False],
                ]
            ),
            [5, 2, 2, 2, 5],
        ),  # 2x2 centre block in 4x4 grid
        (
            np.zeros((0, 4), dtype=bool),
            [0],
        ),  # empty mask → sentinel [0]
    ],
)
def test_mask_to_rle_counts(
    mask_2d: npt.NDArray[np.bool_], expected_counts: list[int]
) -> None:
    """_mask_to_rle_counts produces correct COCO F-order run lengths."""
    assert _mask_to_rle_counts(mask_2d).tolist() == expected_counts


@pytest.mark.parametrize(
    ("rle", "height", "width", "expected_mask"),
    [
        (
            np.array([9], dtype=np.int32),
            3,
            3,
            np.zeros((3, 3), dtype=bool),
        ),  # all-False
        (
            np.array([0, 9], dtype=np.int32),
            3,
            3,
            np.ones((3, 3), dtype=bool),
        ),  # all-True
        (
            np.array([1, 2, 1], dtype=np.int32),
            2,
            2,
            np.array([[False, True], [True, False]]),
        ),  # F-order [F,T,T,F]
        (
            np.array([0, 1, 2, 1], dtype=np.int32),
            2,
            2,
            np.array([[True, False], [False, True]]),
        ),  # F-order [T,F,F,T]
        (
            np.array([5, 2, 2, 2, 5], dtype=np.int32),
            4,
            4,
            np.array(
                [
                    [False, False, False, False],
                    [False, True, True, False],
                    [False, True, True, False],
                    [False, False, False, False],
                ]
            ),
        ),  # 2x2 centre block in 4x4 grid
        (
            np.array([3], dtype=np.int32),
            2,
            3,
            np.zeros((2, 3), dtype=bool),
        ),  # RLE encodes only 3 of 6 pixels; remainder padded False
        (
            np.array([0, 10], dtype=np.int32),
            2,
            3,
            np.ones((2, 3), dtype=bool),
        ),  # RLE sum (10) > h*w (6); excess truncated via flat[:num_pixels]
    ],
)
def test_rle_counts_to_mask(
    rle: npt.NDArray[np.int32],
    height: int,
    width: int,
    expected_mask: npt.NDArray[np.bool_],
) -> None:
    """_rle_counts_to_mask reconstructs the correct boolean mask from run lengths."""
    result = _rle_counts_to_mask(rle, height, width)
    np.testing.assert_array_equal(result, expected_mask)


@pytest.mark.parametrize(
    "mask_2d",
    [
        np.zeros((3, 3), dtype=bool),
        np.ones((4, 4), dtype=bool),
        np.array([[False, True], [True, False]]),
        np.array(
            [
                [False, False, False, False],
                [False, True, True, False],
                [False, True, True, False],
                [False, False, False, False],
            ]
        ),
    ],
)
def test_mask_rle_counts_round_trip(mask_2d: npt.NDArray[np.bool_]) -> None:
    """_rle_counts_to_mask(_mask_to_rle_counts(m)) == m for non-empty masks."""
    h, w = mask_2d.shape
    rle = _mask_to_rle_counts(mask_2d)
    recovered = _rle_counts_to_mask(rle, h, w)
    np.testing.assert_array_equal(recovered, mask_2d)


# ---------------------------------------------------------------------------
# xyxy_to_polygons
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("box", "expected_shape"),
    [
        pytest.param(
            np.array([[0, 0, 10, 20]], dtype=np.float32),
            (1, 4, 2),
            id="single-box",
        ),
        pytest.param(
            np.array([[0, 0, 10, 20], [5, 5, 15, 25]], dtype=np.float32),
            (2, 4, 2),
            id="two-boxes",
        ),
        pytest.param(
            np.empty((0, 4), dtype=np.float32),
            (0, 4, 2),
            id="empty",
        ),
    ],
)
def test_xyxy_to_polygons_shape(
    box: npt.NDArray[np.float32], expected_shape: tuple[int, ...]
) -> None:
    """xyxy_to_polygons returns (N, 4, 2) with correct dtype and corner ordering."""
    result = xyxy_to_polygons(box)
    assert result.shape == expected_shape
    assert result.dtype == box.dtype


def test_xyxy_to_polygons_corners() -> None:
    """The four polygon corners match the four corners of the input box."""
    box = np.array([[2, 3, 8, 11]], dtype=np.int32)
    result = xyxy_to_polygons(box)
    # corners: TL, TR, BR, BL
    expected = np.array([[[2, 3], [8, 3], [8, 11], [2, 11]]], dtype=np.int32)
    np.testing.assert_array_equal(result, expected)


# ---------------------------------------------------------------------------
# polygon_to_mask
# ---------------------------------------------------------------------------


def test_polygon_to_mask_fills_interior() -> None:
    """A square polygon fills the interior pixels with 1s."""
    polygon = np.array([[1, 1], [5, 1], [5, 5], [1, 5]], dtype=np.int32)
    mask = polygon_to_mask(polygon, resolution_wh=(8, 8))
    assert mask.shape == (8, 8)
    assert mask.dtype == np.uint8
    # interior pixel should be 1
    assert mask[3, 3] == 1
    # corner outside polygon should be 0
    assert mask[0, 0] == 0


def test_polygon_to_mask_full_canvas() -> None:
    """A polygon covering the entire canvas yields an all-ones mask."""
    w, h = 10, 10
    polygon = np.array([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]], dtype=np.int32)
    mask = polygon_to_mask(polygon, resolution_wh=(w, h))
    assert mask.sum() == w * h


# ---------------------------------------------------------------------------
# mask_to_polygons
# ---------------------------------------------------------------------------


def test_mask_to_polygons_empty_mask_returns_empty_list() -> None:
    """An all-False mask produces no polygons."""
    mask = np.zeros((20, 20), dtype=bool)
    result = mask_to_polygons(mask)
    assert result == []


def test_mask_to_polygons_single_region() -> None:
    """A mask with one connected region produces exactly one polygon."""
    mask = np.zeros((20, 20), dtype=bool)
    mask[4:16, 4:16] = True
    result = mask_to_polygons(mask)
    assert len(result) == 1
    assert result[0].shape[1] == 2


def test_mask_to_polygons_two_regions() -> None:
    """A mask with two disconnected blobs produces two polygons."""
    mask = np.zeros((30, 60), dtype=bool)
    mask[2:8, 2:8] = True
    mask[2:8, 30:36] = True
    result = mask_to_polygons(mask)
    assert len(result) == 2


def test_mask_to_polygons_polygon_to_mask_round_trip() -> None:
    """mask_to_polygons -> polygon_to_mask recovers most of the original mask area."""
    h, w = 20, 20
    mask = np.zeros((h, w), dtype=bool)
    mask[3:14, 3:14] = True
    polygons = mask_to_polygons(mask)
    assert len(polygons) == 1
    recovered = polygon_to_mask(polygons[0], resolution_wh=(w, h)).astype(bool)
    intersection = (mask & recovered).sum()
    union = (mask | recovered).sum()
    iou = intersection / union
    assert iou > 0.9


# ---------------------------------------------------------------------------
# polygon_to_xyxy
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("polygon", "expected"),
    [
        pytest.param(
            np.array([[1, 2], [5, 2], [5, 8], [1, 8]]),
            np.array([1, 2, 5, 8]),
            id="rectangle",
        ),
        pytest.param(
            np.array([[3, 3], [7, 1], [10, 6], [5, 9]]),
            np.array([3, 1, 10, 9]),
            id="irregular-quad",
        ),
        pytest.param(
            np.array([[4, 4]]),
            np.array([4, 4, 4, 4]),
            id="single-point",
        ),
    ],
)
def test_polygon_to_xyxy(
    polygon: npt.NDArray[np.int_], expected: npt.NDArray[np.int_]
) -> None:
    """polygon_to_xyxy returns the tight axis-aligned bounding box."""
    result = polygon_to_xyxy(polygon)
    np.testing.assert_array_equal(result, expected)
