"""Tests for CreateML object-detection annotation load/save and conversion helpers."""

import json
from collections.abc import Callable
from pathlib import Path

import numpy as np
import pytest

from supervision.dataset.core import DetectionDataset
from supervision.dataset.formats.createml import (
    createml_annotations_to_detections,
    detections_to_createml_annotations,
    load_createml_annotations,
    save_createml_annotations,
)
from supervision.detection.core import Detections


class TestCreatemlAnnotationsToDetections:
    @pytest.mark.parametrize(
        ("image_annotations", "class_to_index", "expected_result"),
        [
            pytest.param(
                [],
                {},
                Detections.empty(),
                id="empty-annotations",
            ),
            pytest.param(
                [
                    {
                        "label": "dog",
                        "coordinates": {"x": 50, "y": 50, "width": 20, "height": 20},
                    }
                ],
                {"dog": 0},
                Detections(
                    xyxy=np.array([[40, 40, 60, 60]], dtype=np.float32),
                    class_id=np.array([0], dtype=int),
                ),
                id="single-centre-box-to-xyxy",
            ),
            pytest.param(
                [
                    {
                        "label": "cat",
                        "coordinates": {"x": 10, "y": 10, "width": 4, "height": 4},
                    },
                    {
                        "label": "dog",
                        "coordinates": {"x": 30, "y": 20, "width": 10, "height": 8},
                    },
                ],
                {"cat": 0, "dog": 1},
                Detections(
                    xyxy=np.array([[8, 8, 12, 12], [25, 16, 35, 24]], dtype=np.float32),
                    class_id=np.array([0, 1], dtype=int),
                ),
                id="multi-class-distinct-ids",
            ),
            pytest.param(
                [
                    {
                        "label": "dog",
                        "coordinates": {"x": 10, "y": 10, "width": 4, "height": 4},
                    },
                    {
                        "label": "dog",
                        "coordinates": {"x": 30, "y": 30, "width": 4, "height": 4},
                    },
                ],
                {"dog": 0},
                Detections(
                    xyxy=np.array([[8, 8, 12, 12], [28, 28, 32, 32]], dtype=np.float32),
                    class_id=np.array([0, 0], dtype=int),
                ),
                id="duplicate-labels-two-detections-same-id",
            ),
        ],
    )
    def test_converts_annotations(
        self,
        image_annotations: list[dict],
        class_to_index: dict[str, int],
        expected_result: Detections,
    ) -> None:
        """Converts CreateML annotation list to Detections with correct xyxy and ids."""
        result = createml_annotations_to_detections(
            image_annotations=image_annotations, class_to_index=class_to_index
        )
        np.testing.assert_array_almost_equal(result.xyxy, expected_result.xyxy)
        assert (result.class_id is None) == (expected_result.class_id is None)
        if expected_result.class_id is not None:
            np.testing.assert_array_equal(result.class_id, expected_result.class_id)

    @pytest.mark.parametrize(
        ("image_annotations", "class_to_index"),
        [
            pytest.param(
                [{"label": "dog"}],
                {"dog": 0},
                id="missing-coordinates-key",
            ),
            pytest.param(
                [{"coordinates": {"x": 10, "y": 10, "width": 4, "height": 4}}],
                {"dog": 0},
                id="missing-label-key",
            ),
            pytest.param(
                [{"label": "dog", "coordinates": {"x": 10, "y": 10, "width": 4}}],
                {"dog": 0},
                id="missing-coordinate-subkey",
            ),
            pytest.param(
                [{"label": "dog", "coordinates": None}],
                {"dog": 0},
                id="coordinates-is-none",
            ),
        ],
    )
    def test_raises_on_malformed_annotation(
        self,
        image_annotations: list[dict],
        class_to_index: dict[str, int],
    ) -> None:
        """Raises ValueError with 'Malformed' for any missing required field."""
        with pytest.raises(ValueError, match="Malformed"):
            createml_annotations_to_detections(
                image_annotations=image_annotations,
                class_to_index=class_to_index,
            )


class TestDetectionsToCreatemlAnnotations:
    def test_round_trips_coordinates(self) -> None:
        """Round-trip: xyxy corners convert to CreateML centre+wh and back correctly."""
        detections = Detections(
            xyxy=np.array([[40, 40, 60, 60]], dtype=np.float32),
            class_id=np.array([1], dtype=int),
        )

        result = detections_to_createml_annotations(
            detections=detections, classes=["cat", "dog"]
        )

        assert result == [
            {
                "label": "dog",
                "coordinates": {"x": 50.0, "y": 50.0, "width": 20.0, "height": 20.0},
            }
        ]

    def test_raises_when_class_id_is_none(self) -> None:
        """Raises ValueError when Detections.class_id is None."""
        detections = Detections(xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32))

        with pytest.raises(ValueError, match="class_id"):
            detections_to_createml_annotations(detections=detections, classes=["dog"])


class TestLoadCreatemlAnnotations:
    def test_loads_basic_annotations(self, tmp_path: Path) -> None:
        """Loads classes, image_paths, and Detections from a valid CreateML file."""
        annotations_path = tmp_path / "annotations.json"
        payload = [
            {
                "image": "a.jpg",
                "annotations": [
                    {
                        "label": "dog",
                        "coordinates": {"x": 50, "y": 50, "width": 20, "height": 20},
                    }
                ],
            },
            {"image": "b.jpg", "annotations": []},
        ]
        annotations_path.write_text(json.dumps(payload))

        classes, image_paths, annotations = load_createml_annotations(
            images_directory_path=str(tmp_path),
            annotations_path=str(annotations_path),
        )

        assert classes == ["dog"]
        assert image_paths == [str(tmp_path / "a.jpg"), str(tmp_path / "b.jpg")]
        detections = annotations[str(tmp_path / "a.jpg")]
        np.testing.assert_array_almost_equal(
            detections.xyxy, np.array([[40, 40, 60, 60]], dtype=np.float32)
        )
        np.testing.assert_array_equal(detections.class_id, np.array([0], dtype=int))
        assert len(annotations[str(tmp_path / "b.jpg")]) == 0

    def test_rejects_duplicate_resolved_image_paths(self, tmp_path: Path) -> None:
        """Aliases for the same file resolve to one canonical CreateML entry."""
        annotations_path = tmp_path / "annotations.json"
        (tmp_path / "nested").mkdir()
        payload = [
            {"image": "a.jpg", "annotations": []},
            {"image": "nested/../a.jpg", "annotations": []},
        ]
        annotations_path.write_text(json.dumps(payload))

        with pytest.raises(ValueError, match="duplicate entries for image"):
            load_createml_annotations(
                images_directory_path=str(tmp_path),
                annotations_path=str(annotations_path),
            )

    def test_rejects_unresolvable_image_path(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Unresolvable image paths are rejected before they enter the dataset."""
        annotations_path = tmp_path / "annotations.json"
        payload = [{"image": "bad.jpg", "annotations": []}]
        annotations_path.write_text(json.dumps(payload))

        original_resolve = Path.resolve

        def fake_resolve(self: Path, *args: object, **kwargs: object) -> Path:
            if self == tmp_path / "bad.jpg":
                raise OSError("unresolvable path")
            return original_resolve(self, *args, **kwargs)

        monkeypatch.setattr(Path, "resolve", fake_resolve)

        with pytest.raises(ValueError, match="invalid path"):
            load_createml_annotations(
                images_directory_path=str(tmp_path),
                annotations_path=str(annotations_path),
            )

    def test_rejects_image_path_resolving_to_directory(self, tmp_path: Path) -> None:
        """CreateML loader rejects entries that resolve to a directory."""
        annotations_path = tmp_path / "annotations.json"
        (tmp_path / "nested").mkdir()
        payload = [{"image": "nested", "annotations": []}]
        annotations_path.write_text(json.dumps(payload))

        with pytest.raises(ValueError, match="directory"):
            load_createml_annotations(
                images_directory_path=str(tmp_path),
                annotations_path=str(annotations_path),
            )

    def test_assigns_global_sorted_class_ids(self, tmp_path: Path) -> None:
        """Class ids are globally sorted regardless of per-image label order."""
        annotations_path = tmp_path / "annotations.json"
        payload = [
            {
                "image": "a.jpg",
                "annotations": [
                    {
                        "label": "zebra",
                        "coordinates": {"x": 10, "y": 10, "width": 4, "height": 4},
                    }
                ],
            },
            {
                "image": "b.jpg",
                "annotations": [
                    {
                        "label": "ant",
                        "coordinates": {"x": 20, "y": 20, "width": 6, "height": 6},
                    }
                ],
            },
        ]
        annotations_path.write_text(json.dumps(payload))

        classes, image_paths, annotations = load_createml_annotations(
            images_directory_path=str(tmp_path),
            annotations_path=str(annotations_path),
        )

        assert classes == ["ant", "zebra"]
        assert image_paths == [str(tmp_path / "a.jpg"), str(tmp_path / "b.jpg")]
        np.testing.assert_array_equal(
            annotations[str(tmp_path / "a.jpg")].class_id, np.array([1], dtype=int)
        )
        np.testing.assert_array_equal(
            annotations[str(tmp_path / "b.jpg")].class_id, np.array([0], dtype=int)
        )

    @pytest.mark.parametrize(
        ("setup_fn", "match"),
        [
            pytest.param(
                lambda p: ("../evil.jpg", str(p / "images")),
                "outside",
                id="path-traversal",
            ),
            pytest.param(
                lambda p: (str(p.parent / "evil.jpg"), str(p)),
                "outside",
                id="absolute-outside",
            ),
            pytest.param(
                lambda p: (".", str(p)),
                "directory",
                id="resolves-to-images-dir",
            ),
        ],
    )
    def test_raises_on_unsafe_image_path(
        self,
        tmp_path: Path,
        setup_fn: Callable[[Path], tuple[str, str]],
        match: str,
    ) -> None:
        """Raises ValueError for unsafe image path: traversal, absolute, directory."""
        image, images_dir = setup_fn(tmp_path)
        annotations_path = tmp_path / "annotations.json"
        annotations_path.write_text(json.dumps([{"image": image, "annotations": []}]))

        with pytest.raises(ValueError, match=match):
            load_createml_annotations(
                images_directory_path=images_dir,
                annotations_path=str(annotations_path),
            )

    @pytest.mark.parametrize(
        ("payload", "match"),
        [
            pytest.param(
                {"image": "a.jpg", "annotations": []},
                "JSON list",
                id="root-is-dict",
            ),
            pytest.param(
                [{"annotations": []}],
                "'image'",
                id="missing-image-key",
            ),
            pytest.param(
                [
                    {"image": "a.jpg", "annotations": []},
                    {"image": "a.jpg", "annotations": []},
                ],
                "duplicate",
                id="duplicate-image-entry",
            ),
        ],
    )
    def test_raises_on_malformed_json(
        self,
        tmp_path: Path,
        payload: dict | list,
        match: str,
    ) -> None:
        """Raises ValueError for malformed JSON: bad root, missing key, duplicate."""
        annotations_path = tmp_path / "annotations.json"
        annotations_path.write_text(json.dumps(payload))

        with pytest.raises(ValueError, match=match):
            load_createml_annotations(
                images_directory_path=str(tmp_path),
                annotations_path=str(annotations_path),
            )


class TestSaveCreatemlAnnotations:
    """CreateML export must reject same-basename images before writing."""

    def test_raises_on_duplicate_image_basenames(self, tmp_path: Path) -> None:
        """Duplicate image basenames are rejected instead of becoming duplicates."""
        image_paths = ["dir_a/img.jpg", "dir_b/img.jpg"]
        dataset = DetectionDataset(
            classes=["object"],
            images=image_paths,
            annotations={path: Detections.empty() for path in image_paths},
        )

        with pytest.raises(ValueError, match="CreateML image file"):
            save_createml_annotations(
                dataset=dataset,
                annotations_path=str(tmp_path / "annotations.json"),
            )

    def test_empty_dataset_writes_empty_list(self, tmp_path: Path) -> None:
        """Empty dataset serialises to an empty JSON array."""
        annotations_path = tmp_path / "nested" / "annotations.json"
        dataset = DetectionDataset(classes=[], images=[], annotations={})

        save_createml_annotations(
            dataset=dataset, annotations_path=str(annotations_path)
        )

        assert json.loads(annotations_path.read_text()) == []

    @pytest.mark.parametrize(
        ("classes", "xyxy", "class_id", "decimal"),
        [
            pytest.param(
                ["cat", "dog"],
                np.array([[8, 8, 12, 12], [25, 16, 35, 24]], dtype=np.float32),
                np.array([0, 1], dtype=int),
                6,
                id="integer-coords-multi-class",
            ),
            pytest.param(
                ["dog"],
                np.array([[10.3, 7.9, 44.1, 88.6]], dtype=np.float32),
                np.array([0], dtype=int),
                4,
                id="float-coords",
            ),
        ],
    )
    def test_save_load_round_trip(
        self,
        tmp_path: Path,
        classes: list[str],
        xyxy: np.ndarray,
        class_id: np.ndarray,
        decimal: int,
    ) -> None:
        """Save then load preserves class names, image paths, and bounding boxes."""
        images_directory_path = tmp_path / "images"
        annotations_path = tmp_path / "annotations.json"
        image_paths = [str(images_directory_path / "a.jpg")]
        annotations = {image_paths[0]: Detections(xyxy=xyxy, class_id=class_id)}
        dataset = DetectionDataset(
            classes=classes, images=image_paths, annotations=annotations
        )

        save_createml_annotations(
            dataset=dataset, annotations_path=str(annotations_path)
        )
        loaded_classes, _, loaded_annotations = load_createml_annotations(
            images_directory_path=str(images_directory_path),
            annotations_path=str(annotations_path),
        )

        assert loaded_classes == classes
        loaded = loaded_annotations[image_paths[0]]
        np.testing.assert_array_almost_equal(loaded.xyxy, xyxy, decimal=decimal)
        np.testing.assert_array_equal(loaded.class_id, class_id)
