from __future__ import annotations

from pathlib import Path
from types import SimpleNamespace
from typing import Any

import pytest

from cmvr_edge_ai.contracts import BoundingBox, Detection, ImageFrame
from cmvr_edge_ai.detection.models import (
    PPE_6CLASS_LABELS,
    register_builtin_detection_models,
)
from cmvr_edge_ai.detection.models import yolo as yolo_module
from cmvr_edge_ai.detection.models.yolo import (
    UltralyticsYoloModel,
    YoloDependencyError,
)
from cmvr_edge_ai.detection.registry import DetectionModelRegistry


class _FakeArray:
    def __init__(self, data: bytes, *, channel_reversed: bool = False) -> None:
        self.data = data
        self.channel_reversed = channel_reversed
        self.shape: tuple[int, ...] | None = None

    def reshape(self, shape: tuple[int, ...]) -> _FakeArray:
        self.shape = shape
        return self

    def __getitem__(self, key: object) -> _FakeArray:
        assert key == (slice(None), slice(None), slice(None, None, -1))
        reversed_array = _FakeArray(self.data, channel_reversed=True)
        reversed_array.shape = self.shape
        return reversed_array


class _FakeNumpy:
    uint8 = object()

    def __init__(self) -> None:
        self.frombuffer_calls: list[tuple[bytes, object]] = []

    def frombuffer(self, data: bytes, *, dtype: object) -> _FakeArray:
        self.frombuffer_calls.append((data, dtype))
        return _FakeArray(data)


class _FakeTensor:
    """Exercise the detach/cpu/tolist path used by real torch tensors."""

    def __init__(self, value: list[Any]) -> None:
        self.value = value
        self.detached = False
        self.on_cpu = False

    def detach(self) -> _FakeTensor:
        self.detached = True
        return self

    def cpu(self) -> _FakeTensor:
        self.on_cpu = True
        return self

    def tolist(self) -> list[Any]:
        return self.value


class _FakeBoxes:
    def __init__(
        self,
        xyxy: list[list[float]],
        confidence: list[float],
        classes: list[float],
    ) -> None:
        self.xyxy = _FakeTensor(xyxy)
        self.conf = _FakeTensor(confidence)
        self.cls = _FakeTensor(classes)


def _weights(tmp_path: Path) -> Path:
    path = tmp_path / "best.pt"
    path.write_bytes(b"test-only-placeholder")
    return path


def _install_fake_runtime(
    monkeypatch: pytest.MonkeyPatch,
    *,
    names: object,
    results: list[object] | None = None,
) -> tuple[_FakeNumpy, list[Any]]:
    numpy = _FakeNumpy()
    created: list[Any] = []

    class FakeYOLO:
        def __init__(self, weights: str, *, task: str) -> None:
            self.weights = weights
            self.task = task
            self.names = names
            self.predict_calls: list[dict[str, Any]] = []
            created.append(self)

        def predict(self, **kwargs: Any) -> list[object]:
            self.predict_calls.append(kwargs)
            return [] if results is None else results

    ultralytics = SimpleNamespace(YOLO=FakeYOLO)

    def fake_import_module(name: str) -> object:
        if name == "numpy":
            return numpy
        if name == "ultralytics":
            return ultralytics
        raise AssertionError(f"unexpected lazy import: {name}")

    monkeypatch.setattr(yolo_module, "import_module", fake_import_module)
    return numpy, created


def _frame(
    *,
    pixel_format: str = "BGR8",
    codec: str = "none",
    data: bytes = bytes(range(12)),
) -> ImageFrame:
    return ImageFrame(
        data=data,
        width=2,
        height=2,
        pixel_format=pixel_format,
        codec=codec,
    )


def test_yolo_dependency_is_loaded_lazily(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    calls: list[str] = []
    fake_numpy = _FakeNumpy()

    def missing_ultralytics(name: str) -> object:
        calls.append(name)
        if name == "numpy":
            return fake_numpy
        raise ImportError("ultralytics is intentionally unavailable")

    monkeypatch.setattr(yolo_module, "import_module", missing_ultralytics)
    model = UltralyticsYoloModel(
        {"weights": str(_weights(tmp_path))},
        expected_labels=("No-Helmet",),
    )

    assert calls == []
    with pytest.raises(YoloDependencyError, match=r"cmvr-edge-ai\[yolo\]"):
        model.load()
    assert calls == ["numpy", "ultralytics"]


def test_yolo_load_requires_checkpoint_labels_to_match_registration_exactly(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    weights = _weights(tmp_path)
    _, created = _install_fake_runtime(
        monkeypatch,
        names={0: "Vest", 1: "No-Helmet"},
    )
    model = UltralyticsYoloModel(
        {"weights": str(weights)},
        expected_labels=("No-Helmet", "Vest"),
    )

    with pytest.raises(ValueError, match="checkpoint labels do not match"):
        model.load()
    assert len(created) == 1
    assert created[0].weights == str(weights)
    assert created[0].task == "detect"

    # A complete, ordered mapping is accepted; comparison is not set-based.
    _, accepted = _install_fake_runtime(
        monkeypatch,
        names={0: "No-Helmet", 1: "Vest"},
    )
    matching = UltralyticsYoloModel(
        {"weights": str(weights)},
        expected_labels=("No-Helmet", "Vest"),
    )
    matching.load()
    assert len(accepted) == 1


def test_ppe_6classes_builtin_registration_preserves_checkpoint_label_order() -> None:
    registry = DetectionModelRegistry()
    register_builtin_detection_models(registry)

    spec = registry.resolve("ppe-6classes-yolov8n@1")

    assert spec.name == "PPE Detection YOLOv8n (6 Classes)"
    assert spec.backend == "ultralytics-yolo"
    assert spec.supported_labels == (
        "Gloves",
        "Vest",
        "goggles",
        "helmet",
        "mask",
        "safety_shoe",
    )
    assert spec.supported_labels == PPE_6CLASS_LABELS


def test_ppe_6classes_factory_enforces_checkpoint_label_order(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    registry = DetectionModelRegistry()
    register_builtin_detection_models(registry)
    spec = registry.resolve("ppe-6classes-yolov8n@1")
    weights = _weights(tmp_path)
    model = spec.factory({"weights": str(weights)})
    _install_fake_runtime(
        monkeypatch,
        names={
            0: "Vest",
            1: "Gloves",
            2: "goggles",
            3: "helmet",
            4: "mask",
            5: "safety_shoe",
        },
    )

    assert isinstance(model, UltralyticsYoloModel)
    with pytest.raises(ValueError, match="checkpoint labels do not match"):
        model.load()

    _, created = _install_fake_runtime(
        monkeypatch,
        names=dict(enumerate(PPE_6CLASS_LABELS)),
    )
    matching = spec.factory({"weights": str(weights)})
    matching.load()

    assert isinstance(matching, UltralyticsYoloModel)
    assert len(created) == 1
    assert created[0].weights == str(weights)
    assert created[0].task == "detect"


@pytest.mark.parametrize(
    ("frame", "message"),
    [
        (_frame(codec="h264"), "decoded ImageFrame"),
        (_frame(pixel_format="GRAY8"), "supports BGR8 or RGB8"),
        (_frame(data=b"too-short"), r"width\*height\*3"),
    ],
)
def test_yolo_rejects_encoded_unsupported_or_malformed_buffers(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    frame: ImageFrame,
    message: str,
) -> None:
    _install_fake_runtime(monkeypatch, names=("No-Helmet",))
    model = UltralyticsYoloModel(
        {"weights": str(_weights(tmp_path))},
        expected_labels=("No-Helmet",),
    )
    model.load()

    with pytest.raises(ValueError, match=message):
        model.predict(frame, ("No-Helmet",), 0.5)


def test_yolo_converts_rgb_to_bgr_but_keeps_bgr_buffer_order(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    numpy, created = _install_fake_runtime(monkeypatch, names=("No-Helmet",))
    model = UltralyticsYoloModel(
        {"weights": str(_weights(tmp_path))},
        expected_labels=("No-Helmet",),
    )
    model.load()

    model.predict(_frame(pixel_format=" BGR8 "), ("No-Helmet",), 0.5)
    bgr_source = created[0].predict_calls[-1]["source"]
    assert isinstance(bgr_source, _FakeArray)
    assert bgr_source.shape == (2, 2, 3)
    assert bgr_source.channel_reversed is False

    model.predict(_frame(pixel_format="RGB8"), ("No-Helmet",), 0.5)
    rgb_source = created[0].predict_calls[-1]["source"]
    assert isinstance(rgb_source, _FakeArray)
    assert rgb_source.shape == (2, 2, 3)
    assert rgb_source.channel_reversed is True
    assert numpy.frombuffer_calls == [
        (bytes(range(12)), numpy.uint8),
        (bytes(range(12)), numpy.uint8),
    ]


def test_yolo_predict_forwards_options_and_normalizes_boxes(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    boxes = _FakeBoxes(
        xyxy=[
            [1.0, 2.0, 30.0, 40.0],
            [5.5, 6.5, 50.5, 60.5],
            [0.0, 0.0, 1.0, 1.0],
        ],
        confidence=[0.91, 0.82, 0.99],
        classes=[1.0, 2.0, 0.0],
    )
    _, created = _install_fake_runtime(
        monkeypatch,
        names={0: "Worker", 1: "No-Helmet", 2: "No-Vest"},
        results=[SimpleNamespace(boxes=boxes)],
    )
    model = UltralyticsYoloModel(
        {
            "weights": str(_weights(tmp_path)),
            "device": "cuda:0",
            "imgsz": 320,
            "iou": 0.45,
            "half": True,
            "max_det": 42,
            "agnostic_nms": True,
        },
        expected_labels=("Worker", "No-Helmet", "No-Vest"),
    )
    model.load()

    detections = model.predict(
        _frame(),
        ("No-Helmet", "No-Vest"),
        0.73,
    )

    call = created[0].predict_calls[-1]
    assert call["classes"] == [1, 2]
    assert call["conf"] == pytest.approx(0.73)
    assert call["iou"] == pytest.approx(0.45)
    assert call["imgsz"] == 320
    assert call["device"] == "cuda:0"
    assert call["half"] is True
    assert call["max_det"] == 42
    assert call["agnostic_nms"] is True
    assert call["verbose"] is False
    assert detections == (
        Detection(
            label="No-Helmet",
            confidence=0.91,
            box=BoundingBox(1.0, 2.0, 30.0, 40.0),
        ),
        Detection(
            label="No-Vest",
            confidence=0.82,
            box=BoundingBox(5.5, 6.5, 50.5, 60.5),
        ),
    )
    assert boxes.xyxy.detached and boxes.xyxy.on_cpu
    assert boxes.conf.detached and boxes.conf.on_cpu
    assert boxes.cls.detached and boxes.cls.on_cpu


def test_yolo_close_is_idempotent_and_requires_reload_before_predict(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    _install_fake_runtime(monkeypatch, names=("No-Helmet",))
    model = UltralyticsYoloModel(
        {"weights": str(_weights(tmp_path))},
        expected_labels=("No-Helmet",),
    )
    model.load()
    model.close()
    model.close()

    with pytest.raises(RuntimeError, match="has not been loaded"):
        model.predict(_frame(), ("No-Helmet",), 0.5)
