from contextlib import ExitStack as DoesNotRaise
from pathlib import Path

import numpy as np
import pytest
from PIL import Image

from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.dataset.core import DetectionDataset
from supervision.dataset.formats.yolo import (
    _extract_class_names,
    _image_name_to_annotation_name,
    _with_seg_mask,
    detections_to_yolo_annotations,
    load_yolo_annotations,
    object_to_yolo,
    yolo_annotations_to_detections,
)
from supervision.detection.core import Detections


def _mock_simple_mask(resolution_wh: tuple[int, int], box: list[int]) -> np.ndarray:
    x_min, y_min, x_max, y_max = box
    mask = np.full(resolution_wh, False, dtype=bool)
    mask[y_min:y_max, x_min:x_max] = True
    return mask


# The result of _mock_simple_mask is a little different from the result produced by cv2.
def _arrays_almost_equal(
    arr1: np.ndarray, arr2: np.ndarray, threshold: float = 0.99
) -> bool:
    equal_elements = np.equal(arr1, arr2)
    proportion_equal = np.mean(equal_elements)
    return proportion_equal >= threshold


@pytest.mark.parametrize(
    ("lines", "expected_result", "exception"),
    [
        ([], False, DoesNotRaise()),  # empty yolo annotation file
        (
            ["0 0.5 0.5 0.2 0.2"],
            False,
            DoesNotRaise(),
        ),  # yolo annotation file with single line with box
        (
            ["0 0.50 0.50 0.20 0.20", "1 0.11 0.47 0.22 0.30"],
            False,
            DoesNotRaise(),
        ),  # yolo annotation file with two lines with box
        (
            ["0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6"],
            True,
            DoesNotRaise(),
        ),  # yolo annotation file with single line with polygon
        (
            ["0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6", "1 0.11 0.47 0.22 0.30"],
            True,
            DoesNotRaise(),
        ),  # yolo annotation file with two lines - one box and one polygon
    ],
)
def test_with_mask(
    lines: list[str], expected_result: bool | None, exception: Exception
) -> None:
    with exception:
        result = _with_seg_mask(lines=lines)
        assert result == expected_result


@pytest.mark.parametrize(
    ("lines", "resolution_wh", "with_masks", "expected_result", "exception"),
    [
        (
            [],
            (1000, 1000),
            False,
            Detections.empty(),
            DoesNotRaise(),
        ),  # empty yolo annotation file
        (
            ["0 0.5 0.5 0.2 0.2"],
            (1000, 1000),
            False,
            Detections(
                xyxy=np.array([[400, 400, 600, 600]], dtype=np.float32),
                class_id=np.array([0], dtype=int),
            ),
            DoesNotRaise(),
        ),  # yolo annotation file with single line with box
        (
            ["0 0.50 0.50 0.20 0.20", "1 0.11 0.47 0.22 0.30"],
            (1000, 1000),
            False,
            Detections(
                xyxy=np.array(
                    [[400, 400, 600, 600], [0, 320, 220, 620]], dtype=np.float32
                ),
                class_id=np.array([0, 1], dtype=int),
            ),
            DoesNotRaise(),
        ),  # yolo annotation file with two lines with box
        (
            ["0 0.5 0.5 0.2 0.2"],
            (1000, 1000),
            True,
            Detections(
                xyxy=np.array([[400, 400, 600, 600]], dtype=np.float32),
                class_id=np.array([0], dtype=int),
                mask=np.array(
                    [
                        _mock_simple_mask(
                            resolution_wh=(1000, 1000), box=[400, 400, 600, 600]
                        )
                    ],
                    dtype=bool,
                ),
            ),
            DoesNotRaise(),
        ),  # yolo annotation file with single line with box in with_masks mode
        (
            ["0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6"],
            (1000, 1000),
            True,
            Detections(
                xyxy=np.array([[400, 400, 600, 600]], dtype=np.float32),
                class_id=np.array([0], dtype=int),
                mask=np.array(
                    [
                        _mock_simple_mask(
                            resolution_wh=(1000, 1000), box=[400, 400, 600, 600]
                        )
                    ],
                    dtype=bool,
                ),
            ),
            DoesNotRaise(),
        ),  # yolo annotation file with single line with polygon
        (
            ["0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6", "1 0.11 0.47 0.22 0.30"],
            (1000, 1000),
            True,
            Detections(
                xyxy=np.array(
                    [[400, 400, 600, 600], [0, 320, 220, 620]], dtype=np.float32
                ),
                class_id=np.array([0, 1], dtype=int),
                mask=np.array(
                    [
                        _mock_simple_mask(
                            resolution_wh=(1000, 1000), box=[400, 400, 600, 600]
                        ),
                        _mock_simple_mask(
                            resolution_wh=(1000, 1000), box=[0, 320, 220, 620]
                        ),
                    ],
                    dtype=bool,
                ),
            ),
            DoesNotRaise(),
        ),  # yolo annotation file with two lines -
        # one box and one polygon in with_masks mode
        (
            ["0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6", "1 0.11 0.47 0.22 0.30"],
            (1000, 1000),
            False,
            Detections(
                xyxy=np.array(
                    [[400, 400, 600, 600], [0, 320, 220, 620]], dtype=np.float32
                ),
                class_id=np.array([0, 1], dtype=int),
            ),
            DoesNotRaise(),
        ),  # yolo annotation file with two lines - one box and one polygon
        (
            ["0 0.4056 0.4078 0.5967 0.4089 0.5978 0.6012 0.4067 0.5989"],
            (1000, 1000),
            True,
            Detections(
                xyxy=np.array([[405.6, 407.8, 597.8, 601.2]], dtype=np.float32),
                class_id=np.array([0], dtype=int),
                mask=np.array(
                    [
                        _mock_simple_mask(
                            resolution_wh=(1000, 1000), box=[406, 408, 598, 601]
                        )
                    ],
                    dtype=bool,
                ),
            ),
            DoesNotRaise(),
        ),
    ],
)
def test_yolo_annotations_to_detections(
    lines: list[str],
    resolution_wh: tuple[int, int],
    with_masks: bool,
    expected_result: Detections | None,
    exception: Exception,
) -> None:
    with exception:
        result = yolo_annotations_to_detections(
            lines=lines, resolution_wh=resolution_wh, with_masks=with_masks
        )
        assert np.array_equal(result.xyxy, expected_result.xyxy)
        assert np.array_equal(result.class_id, expected_result.class_id)
        assert (
            result.mask is None and expected_result.mask is None
        ) or _arrays_almost_equal(result.mask, expected_result.mask)


@pytest.mark.parametrize(
    ("image_name", "expected_result", "exception"),
    [
        ("image.png", "image.txt", DoesNotRaise()),  # simple png image
        ("image.jpeg", "image.txt", DoesNotRaise()),  # simple jpeg image
        ("image.jpg", "image.txt", DoesNotRaise()),  # simple jpg image
        (
            "image.000.jpg",
            "image.000.txt",
            DoesNotRaise(),
        ),  # jpg image with multiple dots in name
    ],
)
def test_image_name_to_annotation_name(
    image_name: str, expected_result: str | None, exception: Exception
) -> None:
    with exception:
        result = _image_name_to_annotation_name(image_name=image_name)
        assert result == expected_result


@pytest.mark.parametrize(
    ("yaml_text", "expected_names", "exception"),
    [
        (
            "names:\n  '0': background\n  '1': person\n"
            "  '2': car\n  '10': traffic_light\n",
            ["background", "person", "car", "traffic_light"],
            DoesNotRaise(),
        ),  # quoted string numeric keys sort by integer value, not lexicographically
        (
            "names:\n  0: background\n  2: car\n  10: traffic_light\n",
            ["background", "car", "traffic_light"],
            DoesNotRaise(),
        ),  # native int keys (most common YOLO format from Ultralytics/Roboflow)
        (
            "names:\n  cat: 0\n  dog: 1\n",
            ["0", "1"],
            DoesNotRaise(),
        ),  # non-numeric string keys fall back to lexicographic sort
        (
            "names: {}\n",
            [],
            DoesNotRaise(),
        ),  # empty names dict returns empty list
        (
            "names:\n  '--1': ignore\n  '0': person\n",
            None,
            pytest.raises(ValueError, match="mix"),
        ),  # mixed numeric/non-numeric keys raise ValueError
    ],
)
def test_extract_class_names_sorts_numeric_string_keys(
    tmp_path: Path,
    yaml_text: str,
    expected_names: list[str] | None,
    exception: Exception,
) -> None:
    """_extract_class_names returns class names sorted by class index."""
    data_yaml_path = tmp_path / "data.yaml"
    data_yaml_path.write_text(yaml_text, encoding="utf-8")
    with exception:
        assert _extract_class_names(file_path=str(data_yaml_path)) == expected_names


@pytest.mark.parametrize(
    ("xyxy", "class_id", "image_shape", "polygon", "expected_result", "exception"),
    [
        (
            np.array([100, 100, 200, 200], dtype=np.float32),
            1,
            (1000, 1000, 3),
            None,
            "1 0.15000 0.15000 0.10000 0.10000",
            DoesNotRaise(),
        ),  # square bounding box on square image
        (
            np.array([100, 100, 200, 200], dtype=np.float32),
            1,
            (800, 1000, 3),
            None,
            "1 0.15000 0.18750 0.10000 0.12500",
            DoesNotRaise(),
        ),  # square bounding box on horizontal image
        (
            np.array([100, 100, 200, 200], dtype=np.float32),
            1,
            (1000, 800, 3),
            None,
            "1 0.18750 0.15000 0.12500 0.10000",
            DoesNotRaise(),
        ),  # square bounding box on vertical image
        (
            np.array([100, 200, 200, 400], dtype=np.float32),
            1,
            (1000, 1000, 3),
            None,
            "1 0.15000 0.30000 0.10000 0.20000",
            DoesNotRaise(),
        ),  # horizontal bounding box on square image
        (
            np.array([200, 100, 400, 200], dtype=np.float32),
            1,
            (1000, 1000, 3),
            None,
            "1 0.30000 0.15000 0.20000 0.10000",
            DoesNotRaise(),
        ),  # vertical bounding box on square image
        (
            np.array([100, 100, 200, 200], dtype=np.float32),
            1,
            (1000, 1000, 3),
            np.array(
                [[100, 100], [200, 100], [200, 200], [100, 100]], dtype=np.float32
            ),
            "1 0.10000 0.10000 0.20000 0.10000 0.20000 0.20000 0.10000 0.10000",
            DoesNotRaise(),
        ),  # square mask on square image
    ],
)
def test_object_to_yolo(
    xyxy: np.ndarray,
    class_id: int,
    image_shape: tuple[int, int, int],
    polygon: np.ndarray | None,
    expected_result: str | None,
    exception: Exception,
) -> None:
    with exception:
        result = object_to_yolo(
            xyxy=xyxy, class_id=class_id, image_shape=image_shape, polygon=polygon
        )
        assert result == expected_result


def test_detections_to_yolo_annotations_raises_for_non_integer_class_id() -> None:
    detections = Detections(
        xyxy=np.array([[100, 100, 200, 200]], dtype=np.float32),
        class_id=np.array([1.9], dtype=np.float32),
    )

    with pytest.raises(ValueError, match="must be an integer"):
        detections_to_yolo_annotations(
            detections=detections, image_shape=(1000, 1000, 3)
        )


@pytest.mark.parametrize(
    ("annotation_line", "load_kwargs", "expect_mask"),
    [
        pytest.param(
            "0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6",
            {"is_obb": True},
            False,
            id="obb-no-mask",
        ),
        pytest.param(
            "0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6",
            {"is_obb": True, "force_masks": True},
            False,
            id="obb-force_masks-ignored",
        ),
        pytest.param(
            "0 0.1 0.1 0.9 0.1 0.9 0.9",
            {"is_obb": False},
            True,
            id="segmentation-produces-mask",
        ),
    ],
)
def test_load_yolo_annotations_mask_behaviour(
    tmp_path: Path,
    annotation_line: str,
    load_kwargs: dict,
    expect_mask: bool,
) -> None:
    """Mask presence depends on annotation format and OBB/segmentation flag."""
    images_dir = tmp_path / "images"
    labels_dir = tmp_path / "labels"
    images_dir.mkdir()
    labels_dir.mkdir()
    Image.new("RGB", (100, 100)).save(images_dir / "test.jpg")
    (labels_dir / "test.txt").write_text(annotation_line + "\n")
    (tmp_path / "data.yaml").write_text("names: ['object']\n")

    _, _, annotations = load_yolo_annotations(
        images_directory_path=str(images_dir),
        annotations_directory_path=str(labels_dir),
        data_yaml_path=str(tmp_path / "data.yaml"),
        **load_kwargs,
    )

    detection = next(iter(annotations.values()))
    assert (detection.mask is not None) == expect_mask


@pytest.mark.parametrize(
    "mode",
    [
        pytest.param("RGBA", id="rgba"),
        pytest.param("P", id="palette"),
    ],
)
def test_load_yolo_annotations_accepts_pil_readable_image_modes(
    tmp_path: Path, mode: str
) -> None:
    """YOLO loading accepts non-RGB images because only dimensions are needed."""
    images_dir = tmp_path / "images"
    labels_dir = tmp_path / "labels"
    images_dir.mkdir()
    labels_dir.mkdir()
    Image.new(mode, (100, 80)).save(images_dir / "test.png")
    (labels_dir / "test.txt").write_text("0 0.5 0.5 0.2 0.4\n")
    (tmp_path / "data.yaml").write_text("names: ['object']\n")

    _, image_paths, annotations = load_yolo_annotations(
        images_directory_path=str(images_dir),
        annotations_directory_path=str(labels_dir),
        data_yaml_path=str(tmp_path / "data.yaml"),
    )

    assert image_paths == [str(images_dir / "test.png")]
    np.testing.assert_allclose(
        annotations[str(images_dir / "test.png")].xyxy,
        np.array([[40.0, 24.0, 60.0, 56.0]], dtype=np.float32),
    )


def test_polygons_to_masks_multiple_polygons_shape() -> None:
    """Regression test for #1746: _polygons_to_masks must return shape (N, H, W).

    The original PR rewrite processed only a single polygon and always returned
    shape (1, H, W), breaking multi-polygon detections.
    """
    from supervision.dataset.formats.yolo import _polygons_to_masks

    resolution_wh = (100, 100)
    # Fractional pixel coords ensure the rounding path inside the function is exercised
    polygon_a = np.array(
        [[10.5, 20.5], [10.5, 50.5], [40.5, 50.5], [40.5, 20.5]], dtype=np.float32
    )
    polygon_b = np.array(
        [[60.3, 30.7], [60.3, 70.3], [90.3, 70.3], [90.3, 30.7]], dtype=np.float32
    )

    masks = _polygons_to_masks(
        polygons=[polygon_a, polygon_b], resolution_wh=resolution_wh
    )

    assert masks.shape == (2, 100, 100), f"Expected (2, 100, 100), got {masks.shape}"
    assert masks.dtype == np.bool_
    assert masks[0].any(), "Polygon A produced an empty mask"
    assert masks[1].any(), "Polygon B produced an empty mask"
    assert not np.any(masks[0] & masks[1]), (
        "Non-overlapping polygons produced overlapping masks"
    )


@pytest.fixture
def yolo_mask_round_trip_sample(
    tmp_path: Path,
) -> tuple[str, str, str, tuple[int, int], str]:
    """Create a minimal YOLO segmentation sample for round-trip mask tests."""
    images_dir = tmp_path / "images"
    labels_dir = tmp_path / "labels"
    images_dir.mkdir()
    labels_dir.mkdir()

    # Odd resolution ensures coord * dim is non-integer (e.g. 0.25 * 101 = 25.25)
    resolution_wh = (101, 97)
    Image.new("RGB", resolution_wh).save(images_dir / "test.jpg")

    original_line = "0 0.25000 0.40000 0.25000 0.60000 0.45000 0.60000 0.45000 0.40000"
    (labels_dir / "test.txt").write_text(original_line + "\n")

    data_yaml_path = tmp_path / "data.yaml"
    data_yaml_path.write_text("names: ['class0']\n")

    return (
        str(images_dir),
        str(labels_dir),
        str(data_yaml_path),
        resolution_wh,
        original_line,
    )


def test_yolo_polygon_mask_precision_no_coord_drift_loads_mask(
    yolo_mask_round_trip_sample: tuple[str, str, str, tuple[int, int], str],
) -> None:
    """YOLO load with force_masks=True should produce a non-empty mask."""
    images_dir, labels_dir, data_yaml_path, _, _ = yolo_mask_round_trip_sample

    _, _, annotations = load_yolo_annotations(
        images_directory_path=images_dir,
        annotations_directory_path=labels_dir,
        data_yaml_path=data_yaml_path,
        force_masks=True,
    )

    assert len(annotations) == 1
    detection = next(iter(annotations.values()))
    assert detection.mask is not None
    assert detection.mask.shape[0] == 1
    assert detection.mask[0].any()


def test_yolo_polygon_mask_precision_no_coord_drift_round_trip_iou(
    yolo_mask_round_trip_sample: tuple[str, str, str, tuple[int, int], str],
) -> None:
    """YOLO load/save round-trip should keep segmentation mask geometry stable."""
    images_dir, labels_dir, data_yaml_path, resolution_wh, original_line = (
        yolo_mask_round_trip_sample
    )

    _, _, annotations = load_yolo_annotations(
        images_directory_path=images_dir,
        annotations_directory_path=labels_dir,
        data_yaml_path=data_yaml_path,
        force_masks=True,
    )
    detection = next(iter(annotations.values()))

    image_arr = np.zeros((resolution_wh[1], resolution_wh[0], 3), dtype=np.uint8)
    saved_lines = detections_to_yolo_annotations(
        detections=detection, image_shape=image_arr.shape
    )

    assert len(saved_lines) == 1
    original_detection = yolo_annotations_to_detections(
        lines=[original_line], resolution_wh=resolution_wh, with_masks=True
    )
    saved_detection = yolo_annotations_to_detections(
        lines=saved_lines, resolution_wh=resolution_wh, with_masks=True
    )

    assert original_detection.mask is not None
    assert saved_detection.mask is not None

    original_mask = original_detection.mask[0]
    saved_mask = saved_detection.mask[0]
    intersection = np.logical_and(original_mask, saved_mask).sum()
    union = np.logical_or(original_mask, saved_mask).sum()
    assert union > 0
    # Keep polygon round-trip drift bounded while avoiding vertex-order assumptions.
    iou = intersection / union
    assert iou > 0.95, (
        f"Mask IoU {iou:.6f} too low after YOLO load/save round-trip — "
        "precision regression in polygon mask conversion"
    )


def test_detections_to_yolo_annotations_obb_emits_nine_tokens() -> None:
    """`is_obb=True` must serialize the 4 corners from `data['xyxyxyxy']`."""
    corners = np.array(
        [[[50.0, 10.0], [90.0, 50.0], [50.0, 90.0], [10.0, 50.0]]], dtype=np.float32
    )
    detections = Detections(
        xyxy=np.array([[10.0, 10.0, 90.0, 90.0]], dtype=np.float32),
        class_id=np.array([0], dtype=int),
        data={ORIENTED_BOX_COORDINATES: corners},
    )

    lines = detections_to_yolo_annotations(
        detections=detections, image_shape=(100, 100, 3), is_obb=True
    )

    assert len(lines) == 1
    tokens = lines[0].split()
    assert len(tokens) == 9, (
        f"OBB export must produce 9 tokens (class + 4 (x,y) pairs), got {tokens}"
    )
    assert tokens[0] == "0"
    np.testing.assert_allclose(
        np.array(tokens[1:], dtype=np.float32),
        np.array([0.5, 0.1, 0.9, 0.5, 0.5, 0.9, 0.1, 0.5], dtype=np.float32),
        atol=1e-5,
    )


def test_detections_to_yolo_annotations_obb_raises_without_corners() -> None:
    """`is_obb=True` without `'xyxyxyxy'` in data must fail loudly, not silently."""
    detections = Detections(
        xyxy=np.array([[10.0, 10.0, 90.0, 90.0]], dtype=np.float32),
        class_id=np.array([0], dtype=int),
    )
    with pytest.raises(ValueError, match=ORIENTED_BOX_COORDINATES):
        detections_to_yolo_annotations(
            detections=detections, image_shape=(100, 100, 3), is_obb=True
        )


def test_detections_to_yolo_annotations_obb_empty_emits_no_lines() -> None:
    """`is_obb=True` on an empty `Detections` must not raise — image had no labels."""
    lines = detections_to_yolo_annotations(
        detections=Detections.empty(), image_shape=(100, 100, 3), is_obb=True
    )
    assert lines == []


def test_detections_to_yolo_annotations_obb_multiple_detections() -> None:
    """OBB export must correctly serialize N>1 detections per image."""
    corners = np.array(
        [
            [[50.0, 10.0], [90.0, 50.0], [50.0, 90.0], [10.0, 50.0]],
            [[20.0, 20.0], [80.0, 20.0], [80.0, 40.0], [20.0, 40.0]],
        ],
        dtype=np.float32,
    )
    detections = Detections(
        xyxy=np.array(
            [[10.0, 10.0, 90.0, 90.0], [20.0, 20.0, 80.0, 40.0]], dtype=np.float32
        ),
        class_id=np.array([0, 1], dtype=int),
        data={ORIENTED_BOX_COORDINATES: corners},
    )

    lines = detections_to_yolo_annotations(
        detections=detections, image_shape=(100, 100, 3), is_obb=True
    )

    assert len(lines) == 2, f"Expected 2 annotation lines, got {len(lines)}"
    for i, line in enumerate(lines):
        tokens = line.split()
        assert len(tokens) == 9, (
            f"Detection {i}: expected 9 tokens, got {len(tokens)}: {tokens}"
        )
    assert lines[0].split()[0] == "0"
    assert lines[1].split()[0] == "1"
    np.testing.assert_allclose(
        np.array(lines[1].split()[1:], dtype=np.float32),
        np.array([0.2, 0.2, 0.8, 0.2, 0.8, 0.4, 0.2, 0.4], dtype=np.float32),
        atol=1e-5,
    )


def test_detections_to_yolo_annotations_obb_data_ignored_when_is_obb_false() -> None:
    """OBB data in detections.data must be silently ignored when is_obb=False."""
    corners = np.array(
        [[[50.0, 10.0], [90.0, 50.0], [50.0, 90.0], [10.0, 50.0]]],
        dtype=np.float32,
    )
    detections = Detections(
        xyxy=np.array([[10.0, 10.0, 90.0, 90.0]], dtype=np.float32),
        class_id=np.array([0], dtype=int),
        data={ORIENTED_BOX_COORDINATES: corners},
    )

    lines = detections_to_yolo_annotations(
        detections=detections, image_shape=(100, 100, 3), is_obb=False
    )

    assert len(lines) == 1
    assert len(lines[0].split()) == 5, (
        "Without is_obb=True, OBB data must be ignored and bbox format (5 tokens) used"
    )


@pytest.mark.parametrize(
    ("is_obb_save", "expected_tokens"),
    [
        pytest.param(True, 9, id="obb-save-nine-tokens"),
        pytest.param(False, 5, id="default-save-five-tokens"),
    ],
)
def test_dataset_as_yolo_obb_output_token_count(
    tmp_path: Path, is_obb_save: bool, expected_tokens: int
) -> None:
    """Token count in saved YOLO line reflects the is_obb flag at export time."""
    images_dir = tmp_path / "images"
    labels_dir = tmp_path / "labels"
    images_dir.mkdir()
    labels_dir.mkdir()
    Image.new("RGB", (100, 100)).save(images_dir / "test.jpg")
    (labels_dir / "test.txt").write_text("0 0.5 0.1 0.9 0.5 0.5 0.9 0.1 0.5\n")
    (tmp_path / "data.yaml").write_text("names: ['object']\n")

    loaded = DetectionDataset.from_yolo(
        images_directory_path=str(images_dir),
        annotations_directory_path=str(labels_dir),
        data_yaml_path=str(tmp_path / "data.yaml"),
        is_obb=True,
    )

    out_labels_dir = tmp_path / "out_labels"
    loaded.as_yolo(
        annotations_directory_path=str(out_labels_dir),
        data_yaml_path=str(tmp_path / "out_data.yaml"),
        is_obb=is_obb_save,
    )

    tokens = (out_labels_dir / "test.txt").read_text().split()
    assert len(tokens) == expected_tokens, (
        f"expected {expected_tokens}-token line, got {len(tokens)}: {tokens}"
    )


def test_dataset_as_yolo_obb_round_trip_corner_accuracy(tmp_path: Path) -> None:
    """OBB round-trip via `from_yolo` -> `as_yolo` must preserve the 4 corners."""
    images_dir = tmp_path / "images"
    labels_dir = tmp_path / "labels"
    images_dir.mkdir()
    labels_dir.mkdir()
    # Non-square image and rotated rhombus exercise both axes.
    Image.new("RGB", (200, 100)).save(images_dir / "test.jpg")
    (labels_dir / "test.txt").write_text("0 0.5 0.1 0.9 0.5 0.5 0.9 0.1 0.5\n")
    (tmp_path / "data.yaml").write_text("names: ['object']\n")

    loaded = DetectionDataset.from_yolo(
        images_directory_path=str(images_dir),
        annotations_directory_path=str(labels_dir),
        data_yaml_path=str(tmp_path / "data.yaml"),
        is_obb=True,
    )

    out_labels_dir = tmp_path / "out_labels"
    loaded.as_yolo(
        annotations_directory_path=str(out_labels_dir),
        data_yaml_path=str(tmp_path / "out_data.yaml"),
        is_obb=True,
    )

    reloaded = DetectionDataset.from_yolo(
        images_directory_path=str(images_dir),
        annotations_directory_path=str(out_labels_dir),
        data_yaml_path=str(tmp_path / "out_data.yaml"),
        is_obb=True,
    )
    original = next(iter(loaded.annotations.values()))
    round_tripped = next(iter(reloaded.annotations.values()))
    np.testing.assert_allclose(
        round_tripped.data[ORIENTED_BOX_COORDINATES],
        original.data[ORIENTED_BOX_COORDINATES],
        atol=1e-3,
    )


def test_dataset_as_yolo_obb_round_trip_with_background_image(
    tmp_path: Path,
) -> None:
    """OBB round-trip with a label-less image must not raise ValueError."""
    images_dir = tmp_path / "images"
    labels_dir = tmp_path / "labels"
    images_dir.mkdir()
    labels_dir.mkdir()
    Image.new("RGB", (100, 100)).save(images_dir / "annotated.jpg")
    Image.new("RGB", (100, 100)).save(images_dir / "background.jpg")
    (labels_dir / "annotated.txt").write_text("0 0.5 0.1 0.9 0.5 0.5 0.9 0.1 0.5\n")
    # No labels/background.txt — background image has no annotations.
    (tmp_path / "data.yaml").write_text("names: ['object']\n")

    loaded = DetectionDataset.from_yolo(
        images_directory_path=str(images_dir),
        annotations_directory_path=str(labels_dir),
        data_yaml_path=str(tmp_path / "data.yaml"),
        is_obb=True,
    )

    out_labels_dir = tmp_path / "out_labels"
    loaded.as_yolo(
        annotations_directory_path=str(out_labels_dir),
        data_yaml_path=str(tmp_path / "out_data.yaml"),
        is_obb=True,
    )

    bg_label = out_labels_dir / "background.txt"
    assert bg_label.exists(), "Background image must produce a label file"
    assert bg_label.read_text().strip() == "", (
        "Background image label file must be empty"
    )
