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

import numpy as np
import pytest
import supervision as sv

from rfdetr.datasets.synthetic import (
    DEFAULT_SPLIT_RATIOS,
    SYNTHETIC_SHAPES,
    DatasetSplitRatios,
    _calculate_polygon_area,
    _write_coco_json,
    calculate_boundary_overlap,
    draw_synthetic_shape,
    generate_coco_dataset,
    generate_synthetic_sample,
)


class TestCalculateBoundaryOverlap:
    @pytest.mark.parametrize(
        "bbox,expected_overlap",
        [
            pytest.param(np.array([40.0, 40.0, 60.0, 60.0]), 0.0, id="fully_inside"),
            pytest.param(np.array([-10.0, 40.0, 10.0, 60.0]), 0.5, id="half_outside_horizontally"),
            pytest.param(np.array([110.0, 40.0, 130.0, 60.0]), 1.0, id="fully_outside"),
            pytest.param(np.array([0.0, 0.0, 50.0, 50.0]), 0.0, id="exactly_at_boundary"),
            pytest.param(np.array([50.0, 50.0, 100.0, 100.0]), 0.0, id="exactly_at_max_boundary"),
        ],
    )
    def test_overlap_values(self, bbox, expected_overlap):
        result = calculate_boundary_overlap(bbox, img_size=100)
        assert result == pytest.approx(expected_overlap)


class TestDrawSyntheticShape:
    @pytest.mark.parametrize(
        "shape,color",
        [
            pytest.param("square", sv.Color.RED, id="square_red"),
            pytest.param("triangle", sv.Color.GREEN, id="triangle_green"),
            pytest.param("circle", sv.Color.BLUE, id="circle_blue"),
        ],
    )
    def test_pixels_are_modified(self, shape, color):
        img = np.zeros((100, 100, 3), dtype=np.uint8)
        img_modified, polygon = draw_synthetic_shape(img.copy(), shape, color, (50, 50), 20)
        assert not np.array_equal(img, img_modified)
        assert len(polygon) >= 6
        assert len(polygon) % 2 == 0

    @pytest.mark.parametrize(
        "shape,cx,cy,size",
        [
            pytest.param("square", 50, 50, 20, id="square"),
            pytest.param("triangle", 50, 50, 20, id="triangle"),
            pytest.param("circle", 50, 50, 20, id="circle"),
        ],
    )
    def test_polygon_min_points(self, shape, cx, cy, size):
        """Returned polygon must have at least 3 points (6 values) for COCO."""
        img = np.zeros((100, 100, 3), dtype=np.uint8)
        _, poly = draw_synthetic_shape(img, shape, sv.Color.WHITE, (cx, cy), size)
        assert len(poly) >= 6, f"{shape} polygon has fewer than 6 values: {poly}"
        assert len(poly) % 2 == 0, f"{shape} polygon has an odd number of values: {poly}"

    @pytest.mark.parametrize(
        "shape,cx,cy,size,expected_n_coords",
        [
            pytest.param("square", 50, 50, 20, 8, id="square_4pts"),
            pytest.param("triangle", 50, 50, 20, 6, id="triangle_3pts"),
            pytest.param("circle", 50, 50, 20, 64, id="circle_32pts"),
        ],
    )
    def test_polygon_coord_count(self, shape, cx, cy, size, expected_n_coords):
        """Each shape must return the expected number of flat coordinate values."""
        img = np.zeros((100, 100, 3), dtype=np.uint8)
        _, poly = draw_synthetic_shape(img, shape, sv.Color.WHITE, (cx, cy), size)
        assert len(poly) == expected_n_coords

    def test_square_polygon_matches_bbox(self):
        """Square polygon corners must align with the drawn rectangle bounds."""
        cx, cy, size = 60, 40, 30
        img = np.zeros((100, 100, 3), dtype=np.uint8)
        _, poly = draw_synthetic_shape(img, "square", sv.Color.WHITE, (cx, cy), size)
        hs = size // 2
        expected = [
            float(cx - hs),
            float(cy - hs),
            float(cx - hs + size),
            float(cy - hs),
            float(cx - hs + size),
            float(cy - hs + size),
            float(cx - hs),
            float(cy - hs + size),
        ]
        assert poly == pytest.approx(expected)

    def test_unknown_shape_returns_empty_polygon(self):
        """An unrecognised shape name must return an empty polygon without crashing."""
        img = np.zeros((100, 100, 3), dtype=np.uint8)
        _, poly = draw_synthetic_shape(img, "hexagon", sv.Color.WHITE, (50, 50), 20)
        assert poly == []


class TestGenerateSyntheticSample:
    @pytest.mark.parametrize(
        "img_size,min_objects,max_objects,class_mode",
        [
            pytest.param(100, 1, 3, "shape", id="small_shape_mode"),
            pytest.param(200, 2, 5, "color", id="medium_color_mode"),
            pytest.param(100, 1, 1, "shape", id="single_object"),
            pytest.param(100, 0, 0, "shape", id="zero_objects"),
        ],
    )
    def test_output_shape_and_detection_count(self, img_size, min_objects, max_objects, class_mode):
        img, detections = generate_synthetic_sample(
            img_size=img_size, min_objects=min_objects, max_objects=max_objects, class_mode=class_mode
        )
        assert img.shape == (img_size, img_size, 3)
        assert min_objects <= len(detections) <= max_objects
        assert hasattr(detections, "xyxy")
        assert hasattr(detections, "class_id")

    def test_polygon_data_present(self):
        """detections.data must contain a 'polygons' array with one entry per detection."""
        _, detections = generate_synthetic_sample(img_size=100, min_objects=2, max_objects=4, class_mode="shape")
        assert "polygons" in detections.data
        assert len(detections.data["polygons"]) == len(detections)

    def test_polygon_data_non_empty(self):
        """Each stored polygon must be a non-empty list of floats."""
        _, detections = generate_synthetic_sample(img_size=100, min_objects=1, max_objects=3, class_mode="shape")
        for poly in detections.data["polygons"]:
            assert isinstance(poly, list)
            assert len(poly) >= 6

    def test_zero_objects_polygon_data(self):
        """With zero objects the polygon data array must be present but empty."""
        _, detections = generate_synthetic_sample(img_size=100, min_objects=0, max_objects=0, class_mode="shape")
        assert "polygons" in detections.data
        assert len(detections.data["polygons"]) == 0

    def test_polygon_bbox_consistency(self):
        """detections.xyxy must match the min/max of the corresponding polygon."""
        _, detections = generate_synthetic_sample(img_size=200, min_objects=3, max_objects=5, class_mode="shape")
        for i in range(len(detections)):
            poly = detections.data["polygons"][i]
            poly_array = np.asarray(poly, dtype=float).reshape(-1, 2)
            expected_x_min = float(np.min(poly_array[:, 0]))
            expected_y_min = float(np.min(poly_array[:, 1]))
            expected_x_max = float(np.max(poly_array[:, 0]))
            expected_y_max = float(np.max(poly_array[:, 1]))
            x_min, y_min, x_max, y_max = detections.xyxy[i]
            assert x_min == pytest.approx(expected_x_min), f"detection {i} x_min mismatch"
            assert y_min == pytest.approx(expected_y_min), f"detection {i} y_min mismatch"
            assert x_max == pytest.approx(expected_x_max), f"detection {i} x_max mismatch"
            assert y_max == pytest.approx(expected_y_max), f"detection {i} y_max mismatch"


class TestGenerateCocoDataset:
    @pytest.mark.parametrize(
        "num_images,img_size,class_mode,split_ratios,expected_splits",
        [
            # Test with dictionary (legacy support)
            pytest.param(
                5,
                100,
                "shape",
                {"train": 0.6, "val": 0.2, "test": 0.2},
                ["train", "val", "test"],
                id="shape_mode_all_splits_dict",
            ),
            pytest.param(
                3,
                64,
                "color",
                {"train": 0.5, "val": 0.5},
                ["train", "val"],
                id="color_mode_two_splits_dict",
            ),
            pytest.param(
                2,
                128,
                "shape",
                {"train": 1.0},
                ["train"],
                id="single_split_only_dict",
            ),
            # Test with DatasetSplitRatios dataclass
            pytest.param(
                4,
                100,
                "shape",
                DatasetSplitRatios(train=0.7, val=0.2, test=0.1),
                ["train", "val", "test"],
                id="split_ratios_dataclass",
            ),
            pytest.param(
                3,
                64,
                "color",
                DatasetSplitRatios(train=0.8, val=0.2, test=0.0),
                ["train", "val"],
                id="split_ratios_no_test",
            ),
            # Test with tuple
            pytest.param(
                4,
                100,
                "shape",
                (0.7, 0.2, 0.1),
                ["train", "val", "test"],
                id="split_ratios_tuple_three",
            ),
            pytest.param(
                3,
                64,
                "color",
                (0.8, 0.2),
                ["train", "val"],
                id="split_ratios_tuple_two",
            ),
            # Test with default
            pytest.param(
                10,
                64,
                "shape",
                DEFAULT_SPLIT_RATIOS,
                ["train", "val", "test"],
                id="split_ratios_default",
            ),
        ],
    )
    def test_splits_created(self, num_images, img_size, class_mode, split_ratios, expected_splits, tmp_path):
        output_dir = tmp_path / "test_dataset"
        generate_coco_dataset(
            output_dir=str(output_dir),
            num_images=num_images,
            img_size=img_size,
            class_mode=class_mode,
            split_ratios=split_ratios,
        )

        assert output_dir.exists()
        for split in expected_splits:
            split_dir = output_dir / split
            assert split_dir.exists()
            assert (split_dir / "_annotations.coco.json").exists()

            with open(split_dir / "_annotations.coco.json") as f:
                data = json.load(f)
            assert "images" in data
            assert "annotations" in data
            assert "categories" in data
            for img_info in data["images"]:
                assert (split_dir / img_info["file_name"]).exists()

    @pytest.mark.parametrize(
        "num_images,split_ratios",
        [
            pytest.param(10, (0.33, 0.33, 0.34), id="truncating_ratios"),
            pytest.param(7, (0.7, 0.2, 0.1), id="standard_ratios"),
            pytest.param(5, (0.8, 0.2), id="two_split"),
        ],
    )
    def test_split_image_count_equals_total(self, num_images, split_ratios, tmp_path):
        """Total images assigned across all splits must equal num_images."""
        output_dir = tmp_path / "test_dataset"
        generate_coco_dataset(
            output_dir=str(output_dir),
            num_images=num_images,
            img_size=64,
            class_mode="shape",
            split_ratios=split_ratios,
        )
        total_images = 0
        for split_dir in output_dir.iterdir():
            ann_file = split_dir / "_annotations.coco.json"
            if ann_file.exists():
                with open(ann_file) as fh:
                    total_images += len(json.load(fh)["images"])
        assert total_images == num_images

    @pytest.mark.parametrize(
        "split_ratios,error_message",
        [
            pytest.param(
                (1.1, -0.1),
                "Split ratios must be non-negative",
                id="tuple_negative_ratio",
            ),
            pytest.param(
                {"train": 1.1, "val": -0.1},
                "Split ratios must be non-negative",
                id="dict_negative_ratio",
            ),
            pytest.param(
                (0.5, 0.3),
                "Split ratios must sum to 1.0",
                id="tuple_invalid_sum",
            ),
        ],
    )
    def test_invalid_split_ratios(self, split_ratios, error_message, tmp_path):
        output_dir = tmp_path / "test_dataset"
        with pytest.raises(ValueError, match=error_message):
            generate_coco_dataset(
                output_dir=str(output_dir),
                num_images=5,
                img_size=100,
                class_mode="shape",
                split_ratios=split_ratios,
            )


class TestGenerateCocoDatasetWithSegmentation:
    def test_write_coco_json_raises_when_polygons_key_missing(self, tmp_path):
        """with_segmentation=True must raise if detections.data has no 'polygons' key."""
        annotations_path = tmp_path / "_annotations.coco.json"
        detections = sv.Detections(
            xyxy=np.array([[0.0, 0.0, 10.0, 10.0]], dtype=float),
            class_id=np.array([0], dtype=int),
            data={},  # intentionally no "polygons" key
        )
        with pytest.raises(ValueError, match="no 'polygons' found"):
            _write_coco_json(
                annotations_path=annotations_path,
                classes=["shape"],
                file_paths=["/tmp/synthetic.png"],
                detections_list=[detections],
                img_size=64,
                with_segmentation=True,
            )

    def test_write_coco_json_raises_for_mismatched_inputs(self, tmp_path):
        """Mismatched file/detection list lengths must raise to avoid silent truncation."""
        annotations_path = tmp_path / "_annotations.coco.json"
        detections = sv.Detections(
            xyxy=np.empty((0, 4), dtype=float),
            class_id=np.empty((0,), dtype=int),
            data={"polygons": np.empty(0, dtype=object)},
        )

        with pytest.raises(ValueError, match="file_paths and detections_list must have the same length"):
            _write_coco_json(
                annotations_path=annotations_path,
                classes=["shape"],
                file_paths=["/tmp/a.png", "/tmp/b.png"],
                detections_list=[detections],
                img_size=64,
            )

    def test_creates_files(self, tmp_path):
        """with_segmentation=True must create the same directory/file structure as the default."""
        output_dir = tmp_path / "seg_dataset"
        generate_coco_dataset(
            output_dir=str(output_dir),
            num_images=4,
            img_size=64,
            class_mode="shape",
            split_ratios={"train": 0.75, "val": 0.25},
            with_segmentation=True,
        )
        for split in ("train", "val"):
            assert (output_dir / split / "_annotations.coco.json").exists()

    def test_json_structure(self, tmp_path):
        """COCO JSON produced with segmentation must have the required top-level keys."""
        output_dir = tmp_path / "seg_dataset"
        generate_coco_dataset(
            output_dir=str(output_dir),
            num_images=4,
            img_size=64,
            class_mode="shape",
            split_ratios={"train": 1.0},
            with_segmentation=True,
        )
        with open(output_dir / "train" / "_annotations.coco.json") as fh:
            data = json.load(fh)
        assert "images" in data
        assert "annotations" in data
        assert "categories" in data

    def test_has_polygon_field(self, tmp_path):
        """Every annotation must have a non-empty segmentation polygon."""
        output_dir = tmp_path / "seg_dataset"
        generate_coco_dataset(
            output_dir=str(output_dir),
            num_images=3,
            img_size=64,
            class_mode="shape",
            min_objects=1,
            max_objects=2,
            split_ratios={"train": 1.0},
            with_segmentation=True,
        )
        with open(output_dir / "train" / "_annotations.coco.json") as fh:
            data = json.load(fh)
        assert len(data["annotations"]) > 0, "Expected at least one annotation"
        for ann in data["annotations"]:
            assert "segmentation" in ann
            assert isinstance(ann["segmentation"], list)
            assert len(ann["segmentation"]) == 1, "Expected exactly one polygon per annotation"
            assert len(ann["segmentation"][0]) >= 6, "Polygon must have at least 3 points"

    def test_area_uses_polygon_when_segmentation_enabled(self, tmp_path):
        """COCO area must match polygon area when segmentation annotations are present."""
        annotations_path = tmp_path / "_annotations.coco.json"
        polygon_data = np.empty(1, dtype=object)
        polygon_data[0] = [0.0, 0.0, 10.0, 0.0, 0.0, 10.0]  # Right triangle area = 50
        detections = sv.Detections(
            xyxy=np.array([[0.0, 0.0, 10.0, 10.0]], dtype=float),
            class_id=np.array([0], dtype=int),
            data={"polygons": polygon_data},
        )

        _write_coco_json(
            annotations_path=annotations_path,
            classes=["shape"],
            file_paths=["/tmp/synthetic.png"],
            detections_list=[detections],
            img_size=64,
            with_segmentation=True,
        )

        with open(annotations_path) as fh:
            data = json.load(fh)

        assert len(data["annotations"]) == 1
        assert data["annotations"][0]["area"] == pytest.approx(50.0)

    def test_sparse_category_ids(self, tmp_path):
        """Category IDs must use sparse 1-based encoding (1, 3, 5, …)."""
        output_dir = tmp_path / "seg_dataset"
        generate_coco_dataset(
            output_dir=str(output_dir),
            num_images=4,
            img_size=64,
            class_mode="shape",
            split_ratios={"train": 1.0},
            with_segmentation=True,
        )
        with open(output_dir / "train" / "_annotations.coco.json") as fh:
            data = json.load(fh)
        cat_ids = {c["id"] for c in data["categories"]}
        expected_ids = {idx * 2 + 1 for idx in range(len(SYNTHETIC_SHAPES))}
        assert cat_ids == expected_ids
        ann_cat_ids = {a["category_id"] for a in data["annotations"]}
        assert ann_cat_ids.issubset(expected_ids)

    def test_images_exist(self, tmp_path):
        """All images referenced in the JSON must exist on disk."""
        output_dir = tmp_path / "seg_dataset"
        generate_coco_dataset(
            output_dir=str(output_dir),
            num_images=3,
            img_size=64,
            class_mode="shape",
            split_ratios={"train": 1.0},
            with_segmentation=True,
        )
        split_dir = output_dir / "train"
        with open(split_dir / "_annotations.coco.json") as fh:
            data = json.load(fh)
        for img_info in data["images"]:
            assert (split_dir / img_info["file_name"]).exists()

    def test_empty_polygon_falls_back_to_empty_segmentation(self, tmp_path):
        """An empty polygon entry silently falls back to ``segmentation=[]``.

        The ``len(polygon_data) < len(detections)`` guard only checks array length, not contents.  An element that is an
        empty list passes the guard and takes the ``else`` branch producing ``segmentation=[]``. This test documents the
        existing silent-fallback behaviour.
        """
        annotations_path = tmp_path / "_annotations.coco.json"
        polygon_data = np.empty(1, dtype=object)
        polygon_data[0] = []  # empty polygon — passes length guard
        detections = sv.Detections(
            xyxy=np.array([[0.0, 0.0, 10.0, 10.0]], dtype=float),
            class_id=np.array([0], dtype=int),
            data={"polygons": polygon_data},
        )
        _write_coco_json(
            annotations_path=annotations_path,
            classes=["shape"],
            file_paths=["/tmp/synthetic.png"],
            detections_list=[detections],
            img_size=64,
            with_segmentation=True,
        )
        with open(annotations_path) as fh:
            data = json.load(fh)
        assert data["annotations"][0]["segmentation"] == []


class TestCalculatePolygonArea:
    @pytest.mark.parametrize(
        "polygon,expected_area",
        [
            pytest.param(
                [0.0, 0.0, 10.0, 0.0, 0.0, 10.0],
                50.0,
                id="right_triangle",
            ),
            pytest.param(
                [0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0],
                100.0,
                id="unit_square_10x10",
            ),
            pytest.param(
                [0.0, 0.0, 5.0, 0.0, 10.0, 0.0],
                0.0,
                id="collinear_points_degenerate",
            ),
            pytest.param(
                [0.0, 0.0, 1.0, 1.0],
                0.0,
                id="fewer_than_3_points",
            ),
            pytest.param(
                [],
                0.0,
                id="empty_polygon",
            ),
        ],
    )
    def test_area(self, polygon, expected_area):
        assert _calculate_polygon_area(polygon) == pytest.approx(expected_area)


class TestDrawSyntheticShapeEdgeCases:
    def test_square_polygon_respects_half_size_and_image_bounds_for_odd_size(self):
        """For odd sizes, the square polygon should:

        * Have all vertices within the image bounds.
        * Be horizontally contained within ``cx ± size / 2``.
        """
        cx, cy, size = 50, 50, 21
        img = np.zeros((100, 100, 3), dtype=np.uint8)
        _, poly = draw_synthetic_shape(img, "square", sv.Color.WHITE, (cx, cy), size)

        half_size = size / 2.0
        xs = [poly[i] for i in range(0, len(poly), 2)]
        ys = [poly[i] for i in range(1, len(poly), 2)]

        # All vertices must be inside the image
        assert min(xs) >= 0.0
        assert max(xs) <= float(img.shape[1])
        assert min(ys) >= 0.0
        assert max(ys) <= float(img.shape[0])

        # Horizontal extent should not exceed the intended half-size around cx
        assert min(xs) >= cx - half_size - 1.0
        assert max(xs) <= cx + half_size + 1.0

    def test_triangle_vertices_within_half_size_and_image_bounds(self):
        """Triangle vertices should:

        * Have all vertices within the image bounds.
        * Be vertically contained within ``cy ± size / 2`` so the apex does not
          extend beyond the intended half-size boundary.
        """
        cx, cy, size = 50, 50, 20
        img = np.zeros((100, 100, 3), dtype=np.uint8)
        _, poly = draw_synthetic_shape(img, "triangle", sv.Color.WHITE, (cx, cy), size)

        half_size = size / 2.0
        xs = [poly[i] for i in range(0, len(poly), 2)]
        ys = [poly[i] for i in range(1, len(poly), 2)]

        # All vertices must be inside the image
        assert min(xs) >= 0.0
        assert max(xs) <= float(img.shape[1])
        assert min(ys) >= 0.0
        assert max(ys) <= float(img.shape[0])

        # Vertical extent should not exceed the intended half-size around cy
        assert min(ys) >= cy - half_size - 1.0
        assert max(ys) <= cy + half_size + 1.0

    @pytest.mark.parametrize(
        "shape,size,expected_n_coords",
        [
            pytest.param("square", 0, 8, id="square_size_0"),
            pytest.param("square", 1, 8, id="square_size_1"),
            pytest.param("circle", 0, 64, id="circle_size_0"),
            pytest.param("circle", 1, 64, id="circle_size_1"),
        ],
    )
    def test_degenerate_size_returns_polygon_without_crashing(self, shape, size, expected_n_coords):
        """draw_synthetic_shape with size=0 or size=1 must not raise and must return the expected number of flat
        coordinate values."""
        img = np.zeros((100, 100, 3), dtype=np.uint8)
        _, poly = draw_synthetic_shape(img, shape, sv.Color.WHITE, (50, 50), size)
        assert len(poly) == expected_n_coords
