# ------------------------------------------------------------------------
# RF-DETR
# Copyright (c) 2025 Roboflow. All Rights Reserved.
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
# ------------------------------------------------------------------------

import types
from pathlib import Path
from unittest.mock import MagicMock, patch

import numpy as np
import pytest
import torch
from PIL import Image
from pycocotools.coco import COCO

from rfdetr.datasets.yolo import (
    YoloDetection,
    _extract_yolo_class_names,
    _LazyYoloDetectionDataset,
    _resolve_yolo_split_dirs,
    is_valid_yolo_dataset,
)


def _write_minimal_roboflow_yolo_dataset(tmp_path: Path) -> None:
    """Create a minimal Roboflow YOLO dataset root.

    Example:
        >>> import tempfile
        >>> root = Path(tempfile.mkdtemp())
        >>> _write_minimal_roboflow_yolo_dataset(root)
        >>> (root / "data.yaml").exists()
        True
    """
    (tmp_path / "data.yaml").write_text("names:\n  - person\n", encoding="utf-8")
    for split in ("train", "valid"):
        (tmp_path / split / "images").mkdir(parents=True)
        (tmp_path / split / "labels").mkdir(parents=True)
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(tmp_path / split / "images" / "sample.png")
        (tmp_path / split / "labels" / "sample.txt").write_text("0 0.5 0.5 0.5 0.5\n", encoding="utf-8")


def _write_yolo_segmentation_dataset(tmp_path: Path) -> tuple[Path, Path, Path]:
    """Create a minimal YOLO segmentation dataset on disk.

    Example:
        >>> import tempfile
        >>> root = Path(tempfile.mkdtemp())
        >>> image_dir, label_dir, data_file = _write_yolo_segmentation_dataset(root)
        >>> image_dir.name, label_dir.name, data_file.name
        ('images', 'labels', 'data.yaml')
    """
    image_dir = tmp_path / "images"
    label_dir = tmp_path / "labels"
    image_dir.mkdir()
    label_dir.mkdir()

    image_path = image_dir / "sample.png"
    Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_path)
    (label_dir / "sample.txt").write_text("0 0.25 0.25 0.75 0.25 0.75 0.75 0.25 0.75\n", encoding="utf-8")
    data_file = tmp_path / "data.yaml"
    data_file.write_text("names:\n  0: carton\n", encoding="utf-8")
    return image_dir, label_dir, data_file


def _write_yolo_pose_dataset(tmp_path: Path, *, keypoint_dim: int = 3) -> tuple[Path, Path, Path]:
    """Create a minimal YOLO pose dataset on disk.

    Example:
        >>> import tempfile
        >>> root = Path(tempfile.mkdtemp())
        >>> _, _, data_file = _write_yolo_pose_dataset(root)
        >>> data_file.exists()
        True
    """
    image_dir = tmp_path / "images"
    label_dir = tmp_path / "labels"
    image_dir.mkdir()
    label_dir.mkdir()

    Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "sample.png")
    if keypoint_dim == 3:
        label = "0 0.5 0.5 0.5 0.5 0.25 0.25 2 0 0 0\n"
        yaml_text = """
names:
  0: person
kpt_shape: [2, 3]
kpt_names:
  0: [left_eye, right_eye]
flip_idx: [1, 0]
    """
    else:
        label = "0 0.5 0.5 0.5 0.5 0.25 0.25 0 0\n"
        yaml_text = """
names:
  0: person
kpt_shape: [2, 2]
kpt_names:
  0: [left_eye, right_eye]
    """
    (label_dir / "sample.txt").write_text(label, encoding="utf-8")
    data_file = tmp_path / "data.yaml"
    data_file.write_text(yaml_text, encoding="utf-8")
    return image_dir, label_dir, data_file


class TestBuildRoboflowFromYoloAugConfig:
    """Regression tests for #769: aug_config forwarded to transform builders."""

    def _make_args(self, square_resize_div_64: bool, aug_config=None) -> types.SimpleNamespace:
        return types.SimpleNamespace(
            dataset_dir="/fake/dataset",
            square_resize_div_64=square_resize_div_64,
            aug_config=aug_config,
            segmentation_head=False,
            multi_scale=False,
            expanded_scales=None,
            do_random_resize_via_padding=False,
            patch_size=16,
            num_windows=4,
        )

    @pytest.mark.parametrize(
        "square_resize_div_64,transform_fn,aug_config",
        [
            pytest.param(
                True,
                "make_coco_transforms_square_div_64",
                {"HorizontalFlip": {"p": 0.5}},
                id="square_div_64_with_config",
            ),
            pytest.param(False, "make_coco_transforms", {"HorizontalFlip": {"p": 0.5}}, id="standard_with_config"),
            pytest.param(True, "make_coco_transforms_square_div_64", None, id="square_div_64_none"),
            pytest.param(False, "make_coco_transforms", None, id="standard_none"),
        ],
    )
    def test_aug_config_forwarded_to_transform(
        self, square_resize_div_64: bool, transform_fn: str, aug_config: object
    ) -> None:
        """Regression test for #769: aug_config is forwarded to transform builders for all code paths."""
        args = self._make_args(square_resize_div_64=square_resize_div_64, aug_config=aug_config)

        fake_dirs = (Path("/fake/dataset/train/images"), Path("/fake/dataset/train/labels"))
        with (
            patch("rfdetr.datasets.yolo.Path") as mock_path,
            patch(f"rfdetr.datasets.yolo.{transform_fn}") as mock_transform,
            patch("rfdetr.datasets.yolo.YoloDetection") as mock_dataset,
            patch("rfdetr.datasets.yolo._resolve_yolo_split_dirs", return_value=fake_dirs),
        ):
            mock_path.return_value.exists.return_value = True
            mock_transform.return_value = MagicMock()
            mock_dataset.return_value = MagicMock()

            from rfdetr.datasets.yolo import build_roboflow_from_yolo

            build_roboflow_from_yolo("train", args, resolution=640)

        _, kwargs = mock_transform.call_args
        assert kwargs.get("aug_config") == aug_config, (
            f"{transform_fn} was not called with aug_config={aug_config!r}; got {kwargs}"
        )

    def test_data_yml_selected_when_data_yaml_missing(self, tmp_path: Path) -> None:
        """Regression test: build_roboflow_from_yolo picks data.yml when data.yaml is not present."""
        (tmp_path / "data.yml").touch()
        args = self._make_args(square_resize_div_64=False, aug_config=None)
        args.dataset_dir = str(tmp_path)

        with (
            patch("rfdetr.datasets.yolo.make_coco_transforms") as mock_transform,
            patch("rfdetr.datasets.yolo.YoloDetection") as mock_dataset,
        ):
            mock_transform.return_value = MagicMock()
            mock_dataset.return_value = MagicMock()

            from rfdetr.datasets.yolo import build_roboflow_from_yolo

            build_roboflow_from_yolo("train", args, resolution=640)

        _, kwargs = mock_dataset.call_args
        assert kwargs["data_file"] == str(tmp_path / "data.yml")

    def test_auto_no_cuda_sets_gpu_postprocess_false(self) -> None:
        """Auto + no CUDA must keep CPU normalize by passing gpu_postprocess=False."""
        args = self._make_args(square_resize_div_64=False, aug_config=None)
        args.augmentation_backend = "auto"
        fake_dirs = (Path("/fake/dataset/train/images"), Path("/fake/dataset/train/labels"))
        with (
            patch("rfdetr.datasets.yolo.Path") as mock_path,
            patch("rfdetr.datasets.yolo.make_coco_transforms") as mock_transform,
            patch("rfdetr.datasets.yolo.YoloDetection") as mock_dataset,
            patch("rfdetr.datasets.kornia_transforms._has_cuda_device", return_value=False),
            patch("rfdetr.datasets.yolo._resolve_yolo_split_dirs", return_value=fake_dirs),
        ):
            mock_path.return_value.exists.return_value = True
            mock_transform.return_value = MagicMock()
            mock_dataset.return_value = MagicMock()

            from rfdetr.datasets.yolo import build_roboflow_from_yolo

            build_roboflow_from_yolo("train", args, resolution=640)

        _, kwargs = mock_transform.call_args
        assert kwargs["gpu_postprocess"] is False

    def test_keypoint_mode_rejects_detection_only_yolo_format(self, tmp_path: Path) -> None:
        """Keypoint preview training should fail clearly for YOLO datasets without pose metadata."""
        _write_minimal_roboflow_yolo_dataset(tmp_path)
        args = self._make_args(square_resize_div_64=False, aug_config=None)
        args.dataset_dir = str(tmp_path)
        args.use_grouppose_keypoints = True

        from rfdetr.datasets import build_roboflow

        with pytest.raises(ValueError, match="YOLO keypoint"):
            build_roboflow("train", args, resolution=64)

    def test_keypoint_mode_accepts_yolo_pose_format(self, tmp_path: Path) -> None:
        """Keypoint preview training should build YOLO pose datasets when kpt_shape is present."""
        _write_minimal_roboflow_yolo_dataset(tmp_path)
        (tmp_path / "data.yaml").write_text(
            "names:\n  - person\nkpt_shape: [1, 3]\n",
            encoding="utf-8",
        )
        (tmp_path / "train" / "labels" / "sample.txt").write_text(
            "0 0.5 0.5 0.5 0.5 0.5 0.5 2\n",
            encoding="utf-8",
        )
        args = self._make_args(square_resize_div_64=False, aug_config=None)
        args.dataset_dir = str(tmp_path)
        args.use_grouppose_keypoints = True
        args.num_keypoints_per_class = [1]

        from rfdetr.datasets import build_roboflow

        dataset = build_roboflow("train", args, resolution=64)

        _, target = dataset[0]
        assert target["keypoints"].shape == (1, 1, 3)
        assert target["keypoints"][0, 0, 2].item() == pytest.approx(2.0)


class TestIsValidYoloDataset:
    """Tests for the is_valid_yolo_dataset function."""

    def _create_valid_yolo_dataset(self, tmp_path: Path, yaml_filename: str) -> str:
        """Create a minimal valid YOLO dataset directory structure."""
        (tmp_path / yaml_filename).touch()
        for split in ["train", "valid"]:
            for subdir in ["images", "labels"]:
                (tmp_path / split / subdir).mkdir(parents=True)
        return str(tmp_path)

    @pytest.mark.parametrize(
        "yaml_filename",
        [
            pytest.param("data.yaml", id="data_yaml"),
            pytest.param("data.yml", id="data_yml"),
        ],
    )
    def test_valid_dataset_with_yaml_variants(self, tmp_path: Path, yaml_filename: str) -> None:
        """Regression test: both data.yaml and data.yml are accepted as valid YOLO datasets."""
        dataset_dir = self._create_valid_yolo_dataset(tmp_path, yaml_filename)
        assert is_valid_yolo_dataset(dataset_dir) is True

    def test_invalid_dataset_missing_yaml(self, tmp_path: Path) -> None:
        """Dataset without any YAML file should be invalid."""
        for split in ["train", "valid"]:
            for subdir in ["images", "labels"]:
                (tmp_path / split / subdir).mkdir(parents=True)
        assert is_valid_yolo_dataset(str(tmp_path)) is False

    def test_invalid_dataset_missing_split_dirs(self, tmp_path: Path) -> None:
        """Dataset without required split directories should be invalid."""
        (tmp_path / "data.yaml").touch()
        assert is_valid_yolo_dataset(str(tmp_path)) is False


class TestYoloDetectionLazyMasks:
    """Segmentation masks should stay lightweight until a sample is fetched."""

    def test_segmentation_init_builds_coco_metadata_without_pixel_loading(self, tmp_path: Path) -> None:
        """Dataset construction must not decode pixel data for every image (only metadata is needed at init)."""
        image_dir, label_dir, data_file = _write_yolo_segmentation_dataset(tmp_path)

        # ``Image.open`` is allowed during init to read header metadata (``image.size``),
        # but ``Image.Image.convert`` decodes the full pixel buffer and must not run until
        # ``__getitem__`` is invoked on the lazy dataset.
        with patch.object(
            Image.Image,
            "convert",
            side_effect=AssertionError("Image.convert should not run during init"),
        ):
            dataset = YoloDetection(
                img_folder=str(image_dir),
                lb_folder=str(label_dir),
                data_file=str(data_file),
                transforms=None,
                include_masks=True,
            )

        sample = dataset.sv_dataset.get_image_info(0)
        assert sample.width == 8
        assert sample.height == 6
        assert sample.xyxy.shape == (1, 4)
        assert len(sample.polygons) == 1
        assert dataset.coco.dataset["images"] == [
            {"id": 0, "file_name": str(image_dir / "sample.png"), "height": 6, "width": 8}
        ]
        assert dataset.coco.dataset["annotations"][0]["segmentation"] == []
        assert isinstance(dataset.coco, COCO)

    def test_init_raises_when_masks_and_keypoints_both_enabled(self, tmp_path: Path) -> None:
        """YoloDetection must reject include_masks=True + include_keypoints=True before any I/O."""
        with pytest.raises(ValueError, match="at the same time"):
            YoloDetection(
                img_folder=str(tmp_path / "images"),
                lb_folder=str(tmp_path / "labels"),
                data_file=str(tmp_path / "data.yaml"),
                transforms=None,
                include_masks=True,
                include_keypoints=True,
            )

    def test_detection_init_exposes_real_coco_api_indexes(self, tmp_path: Path) -> None:
        """`dataset.coco` should be a real pycocotools.COCO object with working indexes."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "sample.png")
        (label_dir / "sample.txt").write_text("0 0.5 0.5 0.5 0.5\n", encoding="utf-8")
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - carton\n", encoding="utf-8")

        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_masks=False,
        )

        assert isinstance(dataset.coco, COCO)
        assert dataset.coco.getCatIds() == [0]
        assert dataset.coco.getImgIds() == [0]
        assert dataset.coco.getAnnIds(imgIds=[0], catIds=[0]) == [0]

    def test_pose_init_exposes_keypoint_coco_metadata_without_pixel_loading(self, tmp_path: Path) -> None:
        """YOLO pose construction should synthesize COCO keypoint metadata lazily."""
        image_dir, label_dir, data_file = _write_yolo_pose_dataset(tmp_path, keypoint_dim=3)

        with patch.object(
            Image.Image,
            "convert",
            side_effect=AssertionError("Image.convert should not run during init"),
        ):
            dataset = YoloDetection(
                img_folder=str(image_dir),
                lb_folder=str(label_dir),
                data_file=str(data_file),
                transforms=None,
                include_keypoints=True,
                num_keypoints_per_class=[2],
            )

        sample = dataset.sv_dataset.get_image_info(0)
        assert sample.keypoints.shape == (1, 2, 3)
        assert dataset.coco.dataset["categories"] == [
            {
                "id": 0,
                "name": "person",
                "supercategory": "none",
                "keypoints": ["left_eye", "right_eye"],
                "skeleton": [],
            }
        ]
        assert dataset.coco.dataset["annotations"][0]["num_keypoints"] == 1
        assert dataset.coco.dataset["annotations"][0]["keypoints"] == pytest.approx([2.0, 1.5, 2.0, 0.0, 0.0, 0.0])

    def test_pose_getitem_returns_keypoint_targets_with_visibility(self, tmp_path: Path) -> None:
        """YOLO pose labels with visibility should become RF-DETR keypoint targets."""
        image_dir, label_dir, data_file = _write_yolo_pose_dataset(tmp_path, keypoint_dim=3)
        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_keypoints=True,
            num_keypoints_per_class=[2],
        )

        _, target = dataset[0]

        assert target["boxes"][0].tolist() == pytest.approx([2.0, 1.5, 6.0, 4.5])
        assert target["labels"].tolist() == [0]
        assert target["keypoints"].shape == (1, 2, 3)
        torch.testing.assert_close(
            target["keypoints"][0],
            torch.tensor([[2.0, 1.5, 2.0], [0.0, 0.0, 0.0]], dtype=torch.float32),
        )
        assert "masks" not in target

    def test_pose_2d_keypoints_synthesize_visibility(self, tmp_path: Path) -> None:
        """YOLO pose labels without visibility should mark nonzero points visible and zero points absent."""
        image_dir, label_dir, data_file = _write_yolo_pose_dataset(tmp_path, keypoint_dim=2)
        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_keypoints=True,
            num_keypoints_per_class=[2],
        )

        _, target = dataset[0]

        torch.testing.assert_close(
            target["keypoints"][0],
            torch.tensor([[2.0, 1.5, 2.0], [0.0, 0.0, 0.0]], dtype=torch.float32),
        )

    def test_pose_background_image_has_empty_keypoint_tensor(self, tmp_path: Path) -> None:
        """YOLO pose background images should keep an empty keypoint tensor with the schema width."""
        image_dir, label_dir, data_file = _write_yolo_pose_dataset(tmp_path, keypoint_dim=3)
        (label_dir / "sample.txt").unlink()
        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_keypoints=True,
            num_keypoints_per_class=[2],
        )

        _, target = dataset[0]

        assert target["boxes"].shape == (0, 4)
        assert target["labels"].shape == (0,)
        assert target["keypoints"].shape == (0, 2, 3)

    def test_pose_multi_instance_keypoints_stack_correctly(self, tmp_path: Path) -> None:
        """Multiple YOLO pose rows should stack boxes, labels, and keypoints per instance."""
        image_dir, label_dir, data_file = _write_yolo_pose_dataset(tmp_path, keypoint_dim=3)
        (label_dir / "sample.txt").write_text(
            "0 0.5 0.5 0.5 0.5 0.25 0.25 2 0 0 0\n0 0.25 0.25 0.25 0.25 0.5 0.5 1 0.75 0.75 2\n",
            encoding="utf-8",
        )
        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_keypoints=True,
            num_keypoints_per_class=[2],
        )

        _, target = dataset[0]

        assert target["boxes"].shape == (2, 4)
        assert target["labels"].tolist() == [0, 0]
        assert target["keypoints"].shape == (2, 2, 3)
        torch.testing.assert_close(target["keypoints"][1, :, 2], torch.tensor([1.0, 2.0]))

    def test_pose_malformed_keypoint_count_raises_clear_error(self, tmp_path: Path) -> None:
        """YOLO pose rows must match kpt_shape exactly."""
        image_dir, label_dir, data_file = _write_yolo_pose_dataset(tmp_path, keypoint_dim=3)
        (label_dir / "sample.txt").write_text("0 0.5 0.5 0.5 0.5 0.25 0.25\n", encoding="utf-8")

        with pytest.raises(ValueError, match="kpt_shape"):
            YoloDetection(
                img_folder=str(image_dir),
                lb_folder=str(label_dir),
                data_file=str(data_file),
                transforms=None,
                include_keypoints=True,
                num_keypoints_per_class=[2],
            )

    @pytest.mark.parametrize(
        "bad_label, expected_match",
        [
            pytest.param(
                "0 0.5 0.5 0.5 0.5 0.5 0.5 3.0 0.5 0.5 2.0\n",
                "visibility values must be",
                id="visibility_out_of_range",
            ),
            pytest.param(
                "0 0.5 0.5 0.5 0.5 nan 0.5 2.0 0.5 0.5 2.0\n",
                "non-finite",
                id="nan_keypoint",
            ),
        ],
    )
    def test_pose_malformed_label_value_raises_clear_error(
        self, tmp_path: Path, bad_label: str, expected_match: str
    ) -> None:
        """Out-of-range visibility or NaN keypoints raise ValueError."""
        image_dir, label_dir, data_file = _write_yolo_pose_dataset(tmp_path, keypoint_dim=3)
        (label_dir / "sample.txt").write_text(bad_label, encoding="utf-8")

        with pytest.raises(ValueError, match=expected_match):
            YoloDetection(
                img_folder=str(image_dir),
                lb_folder=str(label_dir),
                data_file=str(data_file),
                transforms=None,
                include_keypoints=True,
            )

    def test_pose_dim3_out_of_bounds_coord_clamped_to_image_edge(self, tmp_path: Path) -> None:
        """Dim-3: OOB keypoint coords are clamped to [0, 1]; visibility flag unchanged.

        Roboflow exports sometimes annotate keypoints slightly outside the image frame. Clamping maps them to the
        nearest edge so training proceeds without crashing. Image fixture is 8 × 6 px.
        """
        image_dir, label_dir, data_file = _write_yolo_pose_dataset(tmp_path, keypoint_dim=3)
        # kpt0: x=1.5 (OOB right), y=0.5, v=2 → clamp x to 1.0 → pixel x = 8.0
        # kpt1: x=-0.1 (OOB left), y=0.5, v=1 → clamp x to 0.0 → pixel x = 0.0; v=1 preserved
        (label_dir / "sample.txt").write_text("0 0.5 0.5 0.5 0.5 1.5 0.5 2.0 -0.1 0.5 1.0\n", encoding="utf-8")

        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_keypoints=True,
            num_keypoints_per_class=[2],
        )

        _, target = dataset[0]
        kpts = target["keypoints"]
        assert kpts.shape == (1, 2, 3)
        assert kpts[0, 0, 0].item() == pytest.approx(8.0)
        assert kpts[0, 0, 2].item() == pytest.approx(2.0)
        assert kpts[0, 1, 0].item() == pytest.approx(0.0)
        assert kpts[0, 1, 2].item() == pytest.approx(1.0)

    def test_pose_dim2_negative_coord_treated_as_absent(self, tmp_path: Path) -> None:
        """Dim-2: negative coordinate is the Ultralytics absent-keypoint sentinel.

        Per the YOLO pose spec, a negative x or y signals the keypoint is not labeled.  The parser must detect absence
        BEFORE clamping so that a keypoint like (-0.1, 0.5) is not clamped to (0.0, 0.5) and mistaken for a present
        keypoint at the left image edge. Image fixture is 8 × 6 px.
        """
        image_dir, label_dir, data_file = _write_yolo_pose_dataset(tmp_path, keypoint_dim=2)
        # kpt0: x=1.5 (OOB, positive) → clamp to 1.0 → pixel x=8; present → v=2
        # kpt1: x=-0.1 (negative absent signal), y=0.5 → absent → coords zeroed, v=0
        (label_dir / "sample.txt").write_text("0 0.5 0.5 0.5 0.5 1.5 0.5 -0.1 0.5\n", encoding="utf-8")

        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_keypoints=True,
            num_keypoints_per_class=[2],
        )

        _, target = dataset[0]
        kpts = target["keypoints"]
        assert kpts.shape == (1, 2, 3)
        # kpt0: present, clamped to right edge
        assert kpts[0, 0, 0].item() == pytest.approx(8.0)
        assert kpts[0, 0, 2].item() == pytest.approx(2.0)
        # kpt1: absent — coords zeroed, visibility 0
        assert kpts[0, 1, 0].item() == pytest.approx(0.0)
        assert kpts[0, 1, 1].item() == pytest.approx(0.0)
        assert kpts[0, 1, 2].item() == pytest.approx(0.0)

    def test_build_dataset_accepts_explicit_yolo_pose_file(self, tmp_path: Path) -> None:
        """dataset_file='yolo' should use the same pose path as Roboflow auto-detection."""
        root = tmp_path / "dataset"
        root.mkdir()
        _write_minimal_roboflow_yolo_dataset(root)
        (root / "data.yaml").write_text("names:\n  - person\nkpt_shape: [1, 3]\n", encoding="utf-8")
        (root / "train" / "labels" / "sample.txt").write_text("0 0.5 0.5 0.5 0.5 0.5 0.5 2\n", encoding="utf-8")
        args = types.SimpleNamespace(
            dataset_dir=str(root),
            dataset_file="yolo",
            square_resize_div_64=False,
            segmentation_head=False,
            multi_scale=False,
            expanded_scales=False,
            do_random_resize_via_padding=False,
            patch_size=16,
            num_windows=4,
            aug_config={},
            augmentation_backend="cpu",
            use_grouppose_keypoints=True,
            num_keypoints_per_class=[1],
            keypoint_flip_pairs=[],
        )

        from rfdetr.datasets import build_dataset

        dataset = build_dataset("train", args, resolution=64)

        _, target = dataset[0]
        assert target["keypoints"].shape == (1, 1, 3)

    def test_rfdetr_aligns_keypoint_schema_from_yolo_pose_yaml(self, tmp_path: Path) -> None:
        """Training setup should infer RF-DETR keypoint schema and flip pairs from YOLO pose metadata."""
        root = tmp_path / "dataset"
        root.mkdir()
        _write_minimal_roboflow_yolo_dataset(root)
        (root / "data.yaml").write_text(
            "names:\n  - person\nkpt_shape: [2, 3]\nflip_idx: [1, 0]\n",
            encoding="utf-8",
        )

        from rfdetr.config import KeypointTrainConfig, RFDETRKeypointPreviewConfig
        from rfdetr.detr import RFDETR

        model = object.__new__(RFDETR)
        model.model_config = RFDETRKeypointPreviewConfig(pretrain_weights=None)
        model.model = types.SimpleNamespace(args=types.SimpleNamespace(num_keypoints_per_class=[]))
        train_config = KeypointTrainConfig(dataset_dir=str(root), dataset_file="yolo", tensorboard=False)

        model._align_keypoint_schema_from_dataset(train_config)

        assert model.model_config.num_keypoints_per_class == [2]
        assert model.model.args.num_keypoints_per_class == [2]
        assert train_config.keypoint_flip_pairs == [0, 1]

    def test_segmentation_masks_are_materialized_per_sample_fetch(self, tmp_path: Path) -> None:
        """Fetching a sample should create the dense boolean mask tensor expected downstream."""
        image_dir, label_dir, data_file = _write_yolo_segmentation_dataset(tmp_path)
        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_masks=True,
        )

        _, target = dataset[0]

        assert target["masks"].dtype == torch.bool
        assert target["masks"].shape == (1, 6, 8)
        assert torch.count_nonzero(target["masks"]) > 0
        assert target["boxes"][0].tolist() == pytest.approx([2.0, 1.5, 6.0, 4.5])

    def test_segmentation_image_with_no_label_produces_empty_sample(self, tmp_path: Path) -> None:
        """Image with no matching .txt label file should produce an empty sample."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "unlabeled.png")
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - carton\n", encoding="utf-8")

        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_masks=True,
        )

        sample = dataset.sv_dataset.get_image_info(0)
        assert sample.xyxy.shape == (0, 4)
        assert sample.class_id.shape == (0,)
        assert sample.polygons == ()

        _, target = dataset[0]
        assert target["masks"].shape == (0, 6, 8)
        assert target["boxes"].shape == (0, 4)

    def test_segmentation_multi_instance_polygons_stack_correctly(self, tmp_path: Path) -> None:
        """Two polygon annotations per image should produce masks with shape (2, H, W)."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "two_instances.png")
        # Two distinct non-overlapping polygons
        (label_dir / "two_instances.txt").write_text(
            "0 0.1 0.1 0.4 0.1 0.4 0.4 0.1 0.4\n1 0.6 0.6 0.9 0.6 0.9 0.9 0.6 0.9\n",
            encoding="utf-8",
        )
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - cat\n  - dog\n", encoding="utf-8")

        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_masks=True,
        )

        _, target = dataset[0]
        assert target["masks"].shape == (2, 6, 8), f"Expected (2, 6, 8), got {target['masks'].shape}"
        assert target["masks"].dtype == torch.bool

    @pytest.mark.parametrize(
        "label_content, match_pattern",
        [
            pytest.param("0\n", "Malformed label", id="only_class_id"),
            pytest.param("0 0.1 0.2 0.3\n", "Malformed label", id="too_few_fields"),
            pytest.param(
                "0 0.1 0.2 0.3 0.4 0.5\n",
                "Malformed polygon",
                id="odd_polygon_coords",
            ),
        ],
    )
    @pytest.mark.parametrize("include_masks", [pytest.param(True, id="masks"), pytest.param(False, id="no_masks")])
    def test_malformed_label_line_raises_clear_error(
        self, tmp_path: Path, label_content: str, match_pattern: str, include_masks: bool
    ) -> None:
        """Malformed label lines should raise a descriptive ValueError with file context."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "bad.png")
        (label_dir / "bad.txt").write_text(label_content, encoding="utf-8")
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - carton\n", encoding="utf-8")

        with pytest.raises(ValueError, match=match_pattern):
            YoloDetection(
                img_folder=str(image_dir),
                lb_folder=str(label_dir),
                data_file=str(data_file),
                transforms=None,
                include_masks=include_masks,
            )

    def test_lazy_dataset_polygon_storage_is_smaller_than_eager_masks(self, tmp_path: Path) -> None:
        """Lazy dataset retains polygon coords, not dense masks — footprint is orders of magnitude smaller."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()

        n_images = 20
        width, height = 256, 256
        for i in range(n_images):
            Image.new("RGB", (width, height)).save(image_dir / f"img_{i:03d}.png")
            # One quadrilateral polygon per image
            (label_dir / f"img_{i:03d}.txt").write_text("0 0.1 0.1 0.9 0.1 0.9 0.9 0.1 0.9\n", encoding="utf-8")
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - obj\n", encoding="utf-8")

        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_masks=True,
        )

        # Bytes actually retained in the lazy samples (polygon coords + bbox + class id)
        lazy_bytes = sum(
            dataset.sv_dataset.get_image_info(i).xyxy.nbytes
            + dataset.sv_dataset.get_image_info(i).class_id.nbytes
            + sum(p.nbytes for p in dataset.sv_dataset.get_image_info(i).polygons)
            for i in range(len(dataset.sv_dataset))
        )

        # Bytes that eager rasterization would have retained (one bool mask per image)
        eager_mask_bytes = n_images * height * width * np.dtype(bool).itemsize

        assert lazy_bytes < eager_mask_bytes / 10, (
            f"Lazy storage ({lazy_bytes} B) should be at least 10× smaller than eager mask cost ({eager_mask_bytes} B)."
        )

    @pytest.mark.parametrize("include_masks", [pytest.param(True, id="masks"), pytest.param(False, id="no_masks")])
    def test_out_of_range_class_id_raises_clear_error(self, tmp_path: Path, include_masks: bool) -> None:
        """A label with a class ID beyond the class count should raise ValueError at init."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "sample.png")
        # Dataset defines 1 class (ID 0); label references class ID 5 — out of range
        (label_dir / "sample.txt").write_text("5 0.25 0.25 0.75 0.25 0.75 0.75 0.25 0.75\n", encoding="utf-8")
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - carton\n", encoding="utf-8")

        with pytest.raises(ValueError, match="out of range"):
            YoloDetection(
                img_folder=str(image_dir),
                lb_folder=str(label_dir),
                data_file=str(data_file),
                transforms=None,
                include_masks=include_masks,
            )

    def test_include_masks_false_uses_lazy_detection_dataset(self, tmp_path: Path) -> None:
        """include_masks=False must use the lazy detection backend (not supervision's DetectionDataset)."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "sample.png")
        (label_dir / "sample.txt").write_text("0 0.5 0.5 0.5 0.5\n", encoding="utf-8")
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - carton\n", encoding="utf-8")

        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_masks=False,
        )

        assert isinstance(dataset.sv_dataset, _LazyYoloDetectionDataset)
        assert len(dataset) == 1
        _, target = dataset[0]
        assert "boxes" in target
        assert "masks" not in target

    def test_detection_image_with_no_label_produces_empty_sample(self, tmp_path: Path) -> None:
        """Detection path: image without a .txt label file should produce an empty sample (background image)."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "unlabeled.png")
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - carton\n", encoding="utf-8")

        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_masks=False,
        )

        assert len(dataset) == 1
        sample = dataset.sv_dataset.get_image_info(0)
        assert sample.xyxy.shape == (0, 4)
        assert sample.class_id.shape == (0,)

        _, target = dataset[0]
        assert target["boxes"].shape == (0, 4)
        assert "masks" not in target

    def test_detection_background_and_labeled_images_counted_together(self, tmp_path: Path) -> None:
        """Detection path: dataset length includes both labeled and background images."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "labeled.png")
        Image.new("RGB", (8, 6), color=(0, 0, 0)).save(image_dir / "unlabeled.png")
        (label_dir / "labeled.txt").write_text("0 0.5 0.5 0.5 0.5\n", encoding="utf-8")
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - carton\n", encoding="utf-8")

        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_masks=False,
        )

        assert len(dataset) == 2

        targets = [dataset[i][1] for i in range(2)]
        box_counts = sorted(t["boxes"].shape[0] for t in targets)
        assert box_counts == [0, 1], f"Expected one background and one annotated sample, got: {box_counts}"

    def test_detection_multi_instance_boxes_stack_correctly(self, tmp_path: Path) -> None:
        """Two bbox annotations per image should produce a (2, 4) boxes tensor with correct class IDs."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "two_boxes.png")
        # Two distinct non-overlapping bounding boxes
        (label_dir / "two_boxes.txt").write_text(
            "0 0.2 0.3 0.2 0.2\n1 0.7 0.7 0.2 0.2\n",
            encoding="utf-8",
        )
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - cat\n  - dog\n", encoding="utf-8")

        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_masks=False,
        )

        _, target = dataset[0]
        assert target["boxes"].shape == (2, 4), f"Expected (2, 4), got {target['boxes'].shape}"
        assert set(target["labels"].tolist()) == {0, 1}

    def test_lazy_getitem_unreadable_image_raises_value_error(self, tmp_path: Path) -> None:
        """Lazy mask loading should raise ValueError when PIL cannot decode the image."""
        image_dir, label_dir, data_file = _write_yolo_segmentation_dataset(tmp_path)
        dataset = YoloDetection(
            img_folder=str(image_dir),
            lb_folder=str(label_dir),
            data_file=str(data_file),
            transforms=None,
            include_masks=True,
        )

        # Replace the on-disk image with non-decodable bytes after dataset init has
        # already captured width/height from the original PNG header.
        (image_dir / "sample.png").write_bytes(b"not a valid image file")

        with pytest.raises(ValueError, match="Could not read image"):
            dataset[0]

    def test_non_integer_class_id_in_label_raises_value_error(self, tmp_path: Path) -> None:
        """A label line with a non-integer class ID must raise ValueError during init."""
        image_dir = tmp_path / "images"
        label_dir = tmp_path / "labels"
        image_dir.mkdir()
        label_dir.mkdir()
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(image_dir / "sample.png")
        # "cat" is not a valid integer class ID
        (label_dir / "sample.txt").write_text("cat 0.5 0.5 0.25 0.25\n", encoding="utf-8")
        data_file = tmp_path / "data.yaml"
        data_file.write_text("names:\n  - carton\n", encoding="utf-8")

        with pytest.raises(ValueError, match="invalid class ID"):
            YoloDetection(
                img_folder=str(image_dir),
                lb_folder=str(label_dir),
                data_file=str(data_file),
                transforms=None,
                include_masks=True,
            )


def _write_ultralytics_yolo_dataset(tmp_path: Path) -> None:
    """Create a minimal Ultralytics-style YOLO dataset with val/ (not valid/) and yaml paths."""
    (tmp_path / "data.yaml").write_text(
        "path: .\ntrain: train/images\nval: val/images\ntest: test/images\nnames:\n  0: person\n",
        encoding="utf-8",
    )
    for split in ("train", "val", "test"):
        (tmp_path / split / "images").mkdir(parents=True)
        (tmp_path / split / "labels").mkdir(parents=True)
        Image.new("RGB", (8, 6), color=(255, 255, 255)).save(tmp_path / split / "images" / "sample.png")
        (tmp_path / split / "labels" / "sample.txt").write_text("0 0.5 0.5 0.5 0.5\n", encoding="utf-8")


class TestResolveYoloSplitDirs:
    """Tests for _resolve_yolo_split_dirs: yaml-aware split path resolution (#1177)."""

    def test_roboflow_layout_without_yaml_paths(self, tmp_path: Path) -> None:
        """Roboflow export layout (valid/, no yaml paths) must keep working unchanged."""
        _write_minimal_roboflow_yolo_dataset(tmp_path)
        data_file = tmp_path / "data.yaml"
        img, lb = _resolve_yolo_split_dirs(tmp_path, data_file, "val")
        assert img == tmp_path / "valid" / "images"
        assert lb == tmp_path / "valid" / "labels"

    def test_ultralytics_layout_val_from_yaml(self, tmp_path: Path) -> None:
        """Ultralytics layout (val/, yaml declares paths) must resolve from yaml."""
        _write_ultralytics_yolo_dataset(tmp_path)
        data_file = tmp_path / "data.yaml"
        img, lb = _resolve_yolo_split_dirs(tmp_path, data_file, "val")
        assert img == tmp_path / "val" / "images"
        assert lb == tmp_path / "val" / "labels"

    def test_ultralytics_layout_train_from_yaml(self, tmp_path: Path) -> None:
        """Train split resolved from yaml paths."""
        _write_ultralytics_yolo_dataset(tmp_path)
        data_file = tmp_path / "data.yaml"
        img, lb = _resolve_yolo_split_dirs(tmp_path, data_file, "train")
        assert img == tmp_path / "train" / "images"
        assert lb == tmp_path / "train" / "labels"

    def test_ultralytics_layout_test_from_yaml(self, tmp_path: Path) -> None:
        """Test split resolved from yaml paths."""
        _write_ultralytics_yolo_dataset(tmp_path)
        data_file = tmp_path / "data.yaml"
        img, lb = _resolve_yolo_split_dirs(tmp_path, data_file, "test")
        assert img == tmp_path / "test" / "images"
        assert lb == tmp_path / "test" / "labels"

    def test_yaml_path_key_resolves_relative_to_root(self, tmp_path: Path) -> None:
        """Ultralytics 'path' key in yaml is used as base for relative split dirs."""
        sub = tmp_path / "datasets" / "coco128"
        sub.mkdir(parents=True)
        for split in ("train", "val"):
            (sub / split / "images").mkdir(parents=True)
            (sub / split / "labels").mkdir(parents=True)
        data_file = tmp_path / "data.yaml"
        data_file.write_text(
            f"path: {sub}\ntrain: train/images\nval: val/images\nnames:\n  0: person\n",
            encoding="utf-8",
        )
        img, lb = _resolve_yolo_split_dirs(tmp_path, data_file, "val")
        assert img == sub / "val" / "images"
        assert lb == sub / "val" / "labels"

    def test_images_suffix_swapped_to_labels(self, tmp_path: Path) -> None:
        """Yaml path ending with /images must have labels dir derived by swapping last component."""
        _write_ultralytics_yolo_dataset(tmp_path)
        data_file = tmp_path / "data.yaml"
        _, lb = _resolve_yolo_split_dirs(tmp_path, data_file, "val")
        assert lb.name == "labels"
        assert lb.parent == (tmp_path / "val")

    def test_fallback_val_dir_when_no_yaml_paths_and_no_valid(self, tmp_path: Path) -> None:
        """When yaml has no path keys and valid/ does not exist but val/ does, use val/."""
        (tmp_path / "data.yaml").write_text("names:\n  0: person\n", encoding="utf-8")
        for split in ("train", "val"):
            (tmp_path / split / "images").mkdir(parents=True)
            (tmp_path / split / "labels").mkdir(parents=True)
        data_file = tmp_path / "data.yaml"
        img, lb = _resolve_yolo_split_dirs(tmp_path, data_file, "val")
        assert img == tmp_path / "val" / "images"
        assert lb == tmp_path / "val" / "labels"

    def test_yaml_declared_path_absent_on_disk_falls_back_to_roboflow(self, tmp_path: Path) -> None:
        """Yaml declares a split path that does not exist; must fall back to Roboflow convention."""
        (tmp_path / "data.yaml").write_text(
            "path: .\ntrain: train/images\nval: nonexistent/images\nnames:\n  0: person\n",
            encoding="utf-8",
        )
        (tmp_path / "valid" / "images").mkdir(parents=True)
        (tmp_path / "valid" / "labels").mkdir(parents=True)
        data_file = tmp_path / "data.yaml"
        img, lb = _resolve_yolo_split_dirs(tmp_path, data_file, "val")
        assert img == tmp_path / "valid" / "images"
        assert lb == tmp_path / "valid" / "labels"

    def test_test_split_falls_back_when_yaml_has_no_test_key(self, tmp_path: Path) -> None:
        """When yaml exists but has no 'test' key, return root/test/images convention."""
        (tmp_path / "data.yaml").write_text(
            "path: .\ntrain: train/images\nval: val/images\nnames:\n  0: person\n",
            encoding="utf-8",
        )
        data_file = tmp_path / "data.yaml"
        img, lb = _resolve_yolo_split_dirs(tmp_path, data_file, "test")
        assert img == tmp_path / "test" / "images"
        assert lb == tmp_path / "test" / "labels"

    def test_path_traversal_in_yaml_does_not_escape_root(self, tmp_path: Path) -> None:
        """Traversal sequence in yaml split path must not escape dataset root."""
        outside = tmp_path.parent / "outside_dataset" / "images"
        outside_labels = tmp_path.parent / "outside_dataset" / "labels"
        outside.mkdir(parents=True)
        outside_labels.mkdir(parents=True)
        (tmp_path / "data.yaml").write_text(
            "path: .\nval: ../outside_dataset/images\nnames:\n  0: person\n",
            encoding="utf-8",
        )
        data_file = tmp_path / "data.yaml"
        img, _ = _resolve_yolo_split_dirs(tmp_path, data_file, "val")
        assert not str(img).startswith(str(tmp_path.parent / "outside_dataset"))


class TestIsValidYoloDatasetUltralytics:
    """is_valid_yolo_dataset must accept both val/ (Ultralytics) and valid/ (Roboflow) layouts."""

    def test_ultralytics_val_dir_accepted(self, tmp_path: Path) -> None:
        """Dataset with val/ instead of valid/ should be recognized as valid YOLO."""
        (tmp_path / "data.yaml").touch()
        for split in ["train", "val"]:
            for subdir in ["images", "labels"]:
                (tmp_path / split / subdir).mkdir(parents=True)
        assert is_valid_yolo_dataset(str(tmp_path)) is True

    def test_roboflow_valid_dir_still_accepted(self, tmp_path: Path) -> None:
        """Existing Roboflow layout (valid/) must keep working."""
        (tmp_path / "data.yaml").touch()
        for split in ["train", "valid"]:
            for subdir in ["images", "labels"]:
                (tmp_path / split / subdir).mkdir(parents=True)
        assert is_valid_yolo_dataset(str(tmp_path)) is True


class TestBuildRoboflowFromYoloUltralytics:
    """build_roboflow_from_yolo must handle Ultralytics YOLO layout (#1177)."""

    def _make_args(self, dataset_dir: str) -> types.SimpleNamespace:
        return types.SimpleNamespace(
            dataset_dir=dataset_dir,
            square_resize_div_64=False,
            aug_config=None,
            segmentation_head=False,
            multi_scale=False,
            expanded_scales=None,
            do_random_resize_via_padding=False,
            patch_size=16,
            num_windows=4,
            augmentation_backend="cpu",
            use_grouppose_keypoints=False,
            num_keypoints_per_class=[],
            keypoint_flip_pairs=[],
        )

    def test_ultralytics_val_split_resolves_correctly(self, tmp_path: Path) -> None:
        """Val split on Ultralytics layout (val/, yaml paths) should not raise."""
        _write_ultralytics_yolo_dataset(tmp_path)
        args = self._make_args(str(tmp_path))

        with (
            patch("rfdetr.datasets.yolo.make_coco_transforms") as mock_transform,
            patch("rfdetr.datasets.yolo.YoloDetection") as mock_dataset,
        ):
            mock_transform.return_value = MagicMock()
            mock_dataset.return_value = MagicMock()

            from rfdetr.datasets.yolo import build_roboflow_from_yolo

            build_roboflow_from_yolo("val", args, resolution=640)

        _, kwargs = mock_dataset.call_args
        assert kwargs["img_folder"] == str(tmp_path / "val" / "images")

    def test_roboflow_layout_still_works(self, tmp_path: Path) -> None:
        """Roboflow export layout (valid/) must keep working after the change."""
        _write_minimal_roboflow_yolo_dataset(tmp_path)
        args = self._make_args(str(tmp_path))

        with (
            patch("rfdetr.datasets.yolo.make_coco_transforms") as mock_transform,
            patch("rfdetr.datasets.yolo.YoloDetection") as mock_dataset,
        ):
            mock_transform.return_value = MagicMock()
            mock_dataset.return_value = MagicMock()

            from rfdetr.datasets.yolo import build_roboflow_from_yolo

            build_roboflow_from_yolo("val", args, resolution=640)

        _, kwargs = mock_dataset.call_args
        assert "valid" in kwargs["img_folder"]


class TestExtractYoloClassNames:
    """Tests for _extract_yolo_class_names with different YAML formats."""

    @pytest.mark.parametrize(
        "yaml_content, expected_names",
        [
            pytest.param(
                "names:\n  - cat\n  - dog\n",
                ["cat", "dog"],
                id="list_format",
            ),
            pytest.param(
                "names:\n  0: cat\n  1: dog\n",
                ["cat", "dog"],
                id="dict_format_sorted_keys",
            ),
            pytest.param(
                "names:\n  1: dog\n  0: cat\n",
                ["cat", "dog"],
                id="dict_format_unsorted_keys",
            ),
        ],
    )
    def test_class_names_formats(self, tmp_path: Path, yaml_content: str, expected_names: list[str]) -> None:
        """Both list and dict YAML formats for class names should be supported."""
        data_file = tmp_path / "data.yaml"
        data_file.write_text(yaml_content, encoding="utf-8")
        assert _extract_yolo_class_names(str(data_file)) == expected_names

    @pytest.mark.parametrize(
        "yaml_content",
        [
            pytest.param(
                "names:\n  0: cat\n  2: dog\n",
                id="dict_format_sparse_keys",
            ),
            pytest.param(
                "names:\n  10: cat\n  20: dog\n",
                id="dict_format_large_numeric_keys",
            ),
        ],
    )
    def test_class_names_dict_non_contiguous_raises(self, tmp_path: Path, yaml_content: str) -> None:
        """Dict 'names' with non-contiguous or non-zero-based keys must raise ValueError.

        The downstream range check in _parse_yolo_label_line assumes class IDs are a contiguous 0..N-1 range.  Silently
        accepting sparse keys would cause valid label files to be rejected during parsing (e.g. class ID 2 in a 2-class
        dataset built from {0: cat, 2: dog} would exceed the num_classes bound).
        """
        data_file = tmp_path / "data.yaml"
        data_file.write_text(yaml_content, encoding="utf-8")
        with pytest.raises(ValueError, match="0..N-1"):
            _extract_yolo_class_names(str(data_file))
