from __future__ import annotations

import asyncio
from dataclasses import asdict
import json
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Any

import pytest

from cmvr_edge_ai.contracts.events import (
    BoundingBox,
    Detection,
    DetectionAlert,
    DetectionResult,
)
from cmvr_edge_ai.contracts.media import EncodedImage, ImageFrame
from cmvr_edge_ai.core import ComponentContext, Emission, Envelope
import cmvr_edge_ai.detection.rules as detection_rules
from cmvr_edge_ai.detection.annotation import ImageAnnotationDependencyError
from cmvr_edge_ai.detection.rules import DetectionTriggerOperator


NS_PER_MS = 1_000_000


def _frame(value: int = 0) -> ImageFrame:
    return ImageFrame(bytes([value]) * 4 * 4 * 3, 4, 4, "BGR8")


async def _keep_restricted_event_loop_responsive() -> None:
    while True:
        await asyncio.sleep(0.01)


def _detection(
    label: str = "No-Helmet",
    confidence: float = 0.9,
    *,
    track_id: str | None = None,
) -> Detection:
    return Detection(
        label=label,
        confidence=confidence,
        box=BoundingBox(1.0, 2.0, 30.0, 40.0),
        track_id=track_id,
    )


def _envelope(
    sequence: int,
    at_ms: float,
    *detections: Detection,
    source_id: str = "camera-a",
    captured: bool = False,
    trace_id: str | None = None,
    frame: ImageFrame | None = None,
) -> Envelope[DetectionResult]:
    timestamp_ns = int(at_ms * NS_PER_MS)
    kwargs: dict[str, Any] = {
        "payload": DetectionResult(
            detections=tuple(detections),
            model_id="construction-ppe-yolov8@1",
            inference_ms=12.5,
            model_name="Construction PPE YOLOv8",
            source_frame=frame,
        ),
        "schema_name": "DetectionResult",
        "schema_version": 1,
        "source_id": source_id,
        "sequence": sequence,
        "received_at_ns": timestamp_ns,
        "captured_at_ns": timestamp_ns if captured else None,
    }
    if trace_id is not None:
        kwargs["trace_id"] = trace_id
    return Envelope(**kwargs)


def _operator(
    *,
    labels: list[str] | None = None,
    min_hits: int = 3,
    window_ms: float = 1_000,
    cooldown_ms: float = 0,
    min_confidence: float = 0.5,
    scope: str = "source",
    time_source: str = "received",
    alert_image: dict[str, Any] | None = None,
) -> DetectionTriggerOperator:
    params: dict[str, Any] = {
        "time_source": time_source,
        "rules": [
            {
                "id": "missing-ppe",
                "labels": labels or ["No-Helmet"],
                "min_hits": min_hits,
                "window_ms": window_ms,
                "cooldown_ms": cooldown_ms,
                "min_confidence": min_confidence,
                "scope": scope,
            }
        ],
    }
    if alert_image is not None:
        params["alert_image"] = alert_image
    return DetectionTriggerOperator("trigger", params)


def _process(
    operator: DetectionTriggerOperator, envelope: Envelope[Any]
) -> tuple[Emission, ...] | None:
    return asyncio.run(operator.process(envelope))


def _single_alert(
    result: tuple[Emission, ...] | None
) -> tuple[Emission, DetectionAlert]:
    assert result is not None
    assert len(result) == 1
    emission = result[0]
    assert isinstance(emission.envelope.payload, DetectionAlert)
    return emission, emission.envelope.payload


@pytest.mark.parametrize(
    ("params", "message"),
    [
        ({"rules": [], "unexpected": True}, "unknown detection trigger"),
        ({"rules": []}, "non-empty list"),
        (
            {"rules": [], "alert_image": "enabled"},
            "alert_image must be a mapping",
        ),
        (
            {"rules": [], "alert_image": {"unexpected": True}},
            "alert_image has unknown field",
        ),
        (
            {"rules": [], "alert_image": {"enabled": "true"}},
            "alert_image.enabled must be a boolean",
        ),
        (
            {"rules": [], "alert_image": {"jpeg_quality": True}},
            "alert_image.jpeg_quality must be an integer",
        ),
        (
            {"rules": [], "alert_image": {"jpeg_quality": 0}},
            "alert_image.jpeg_quality must be between 1 and 95",
        ),
        (
            {"rules": [], "alert_image": {"jpeg_quality": 96}},
            "alert_image.jpeg_quality must be between 1 and 95",
        ),
        (
            {
                "rules": [
                    {
                        "id": "r",
                        "labels": ["No-Helmet"],
                        "min_hits": 2,
                        "window_ms": 100,
                        "typo": True,
                    }
                ]
            },
            "unknown field",
        ),
        (
            {
                "rules": [
                    {
                        "id": "r",
                        "labels": ["No-Helmet", "No-Helmet"],
                        "min_hits": 2,
                        "window_ms": 100,
                    }
                ]
            },
            "duplicates",
        ),
        (
            {
                "rules": [
                    {
                        "id": "r",
                        "labels": ["No-Helmet"],
                        "min_hits": True,
                        "window_ms": 100,
                    }
                ]
            },
            "must be an integer",
        ),
        (
            {
                "time_source": "wall",
                "rules": [
                    {
                        "id": "r",
                        "labels": ["No-Helmet"],
                        "min_hits": 2,
                        "window_ms": 100,
                    }
                ],
            },
            "time_source",
        ),
    ],
)
def test_trigger_configuration_is_strict(params: dict[str, Any], message: str) -> None:
    with pytest.raises(ValueError, match=message):
        DetectionTriggerOperator("trigger", params)


def test_source_rule_counts_distinct_frames_not_boxes_and_preserves_metadata() -> None:
    operator = _operator(min_hits=3, window_ms=1_000, min_confidence=0.7)

    first = _envelope(
        1,
        0,
        _detection(confidence=0.8),
        _detection(confidence=0.91),
        _detection("No-Vest", confidence=0.99),
    )
    assert _process(operator, first) is None
    # Replaying a frame with the same source and sequence must not create a hit.
    assert _process(operator, _envelope(1, 100, _detection(confidence=0.99))) is None
    # Below-threshold detections do not count.
    assert _process(operator, _envelope(2, 500, _detection(confidence=0.69))) is None
    assert _process(operator, _envelope(2, 500, _detection(confidence=0.85))) is None

    threshold = _envelope(
        3,
        1_000,
        _detection(confidence=0.95),
        trace_id="threshold-frame",
    )
    emission, alert = _single_alert(_process(operator, threshold))

    assert emission.port == "alerts"
    assert emission.envelope.schema == "DetectionAlert/v1"
    assert emission.envelope.trace_id == "threshold-frame"
    assert emission.envelope.source_id == "camera-a"
    assert alert.rule_id == "missing-ppe"
    assert alert.model_id == "construction-ppe-yolov8@1"
    assert alert.model_name == "Construction PPE YOLOv8"
    assert alert.labels == ("No-Helmet",)
    assert alert.scope == "source"
    assert alert.scope_id == "camera-a"
    assert alert.hit_count == 3
    assert alert.window_ms == 1_000
    assert alert.first_seen_ns == 0
    assert alert.last_seen_ns == 1_000 * NS_PER_MS
    assert alert.triggered_at_ns == 1_000 * NS_PER_MS
    assert alert.max_confidence == pytest.approx(0.95)
    assert alert.detections == threshold.payload.detections
    assert alert.event_id


def test_invalid_detections_are_counted_and_never_reach_an_alert_payload() -> None:
    operator = _operator(min_hits=1)
    invalid_nan = Detection(
        "No-Helmet",
        0.99,
        BoundingBox(float("nan"), 2.0, 30.0, 40.0),
    )
    invalid_reversed = Detection(
        "No-Helmet",
        0.98,
        BoundingBox(30.0, 40.0, 1.0, 2.0),
    )
    valid = _detection(confidence=0.95)

    assert _process(operator, _envelope(1, 0, invalid_nan, invalid_reversed)) is None
    _, alert = _single_alert(
        _process(
            operator,
            _envelope(2, 10, invalid_nan, invalid_reversed, valid),
        )
    )

    assert alert.detections == (valid,)
    # The remaining structured alert can be emitted by a strict JSON client.
    json.dumps(asdict(alert), allow_nan=False)
    health = asyncio.run(operator.health())
    assert health.healthy is False
    assert "invalid_detections=4" in health.detail


def test_window_lower_boundary_is_inclusive_and_older_hits_expire() -> None:
    inclusive = _operator(min_hits=2, window_ms=1_000)
    assert _process(inclusive, _envelope(1, 0, _detection())) is None
    _, alert = _single_alert(_process(inclusive, _envelope(2, 1_000, _detection())))
    assert alert.first_seen_ns == 0

    expired = _operator(min_hits=2, window_ms=1_000)
    assert _process(expired, _envelope(1, 0, _detection())) is None
    assert _process(expired, _envelope(2, 1_001, _detection())) is None


def test_cooldown_does_not_accumulate_hits_and_requires_a_fresh_window() -> None:
    operator = _operator(min_hits=2, window_ms=1_000, cooldown_ms=500)
    assert _process(operator, _envelope(1, 0, _detection())) is None
    _single_alert(_process(operator, _envelope(2, 100, _detection())))

    # The threshold frame itself remains deduplicated even after the hit deque
    # was reset, and new frames during cooldown are ignored rather than queued.
    assert _process(operator, _envelope(2, 100, _detection())) is None
    assert _process(operator, _envelope(3, 200, _detection())) is None
    assert _process(operator, _envelope(4, 599, _detection())) is None
    assert _process(operator, _envelope(5, 600, _detection())) is None
    _, alert = _single_alert(_process(operator, _envelope(6, 700, _detection())))
    assert alert.first_seen_ns == 600 * NS_PER_MS
    assert alert.last_seen_ns == 700 * NS_PER_MS

    health = asyncio.run(operator.health())
    assert health.healthy is True
    assert "alerts_emitted=2" in health.detail
    assert "cooldown_suppressed=2" in health.detail
    assert "duplicate_frames=1" in health.detail


def test_source_state_is_isolated_per_camera() -> None:
    operator = _operator(min_hits=2)
    assert (
        _process(operator, _envelope(1, 0, _detection(), source_id="camera-a")) is None
    )
    assert (
        _process(operator, _envelope(1, 10, _detection(), source_id="camera-b")) is None
    )

    _, alert = _single_alert(
        _process(operator, _envelope(2, 20, _detection(), source_id="camera-a"))
    )
    assert alert.scope_id == "camera-a"


def test_track_rules_are_isolated_and_can_emit_multiple_alerts_per_frame() -> None:
    operator = _operator(min_hits=2, scope="track")
    first = _envelope(
        1,
        0,
        _detection(track_id="worker-1"),
        _detection(confidence=0.95, track_id="worker-1"),
        _detection(track_id="worker-2"),
        _detection(track_id=None),
    )
    assert _process(operator, first) is None

    result = _process(
        operator,
        _envelope(
            2,
            100,
            _detection(track_id="worker-1"),
            _detection(track_id="worker-2"),
        ),
    )
    assert result is not None
    assert {item.envelope.payload.scope_id for item in result} == {
        "worker-1",
        "worker-2",
    }
    assert all(item.envelope.payload.hit_count == 2 for item in result)
    assert all(item.envelope.payload.scope == "track" for item in result)
    assert "untracked_detections=1" in asyncio.run(operator.health()).detail


def test_multiple_rules_can_trigger_from_one_result() -> None:
    operator = DetectionTriggerOperator(
        "trigger",
        {
            "time_source": "received",
            "rules": [
                {
                    "id": "helmet",
                    "labels": ["No-Helmet"],
                    "min_hits": 1,
                    "window_ms": 100,
                    "cooldown_ms": 0,
                    "min_confidence": 0.5,
                    "scope": "source",
                },
                {
                    "id": "vest",
                    "labels": ["No-Vest"],
                    "min_hits": 1,
                    "window_ms": 100,
                    "cooldown_ms": 0,
                    "min_confidence": 0.5,
                    "scope": "source",
                },
            ],
        },
    )
    result = _process(
        operator,
        _envelope(1, 0, _detection("No-Helmet"), _detection("No-Vest")),
    )

    assert result is not None
    assert [item.envelope.payload.rule_id for item in result] == ["helmet", "vest"]


def test_alert_image_is_encoded_once_only_when_the_threshold_frame_triggers(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    render_calls: list[
        tuple[int, ImageFrame, tuple[Detection, ...], int]
    ] = []
    encoded = EncodedImage(b"jpeg", "image/jpeg", 4, 4)

    def render(
        frame: ImageFrame,
        detections: tuple[Detection, ...],
        *,
        jpeg_quality: int,
    ) -> EncodedImage:
        render_calls.append(
            (threading.get_ident(), frame, tuple(detections), jpeg_quality)
        )
        return encoded

    monkeypatch.setattr(detection_rules, "ensure_annotation_support", lambda: None)
    monkeypatch.setattr(detection_rules, "render_annotated_jpeg", render)
    operator = _operator(
        min_hits=2,
        alert_image={"enabled": True, "jpeg_quality": 73},
    )
    empty_frame = _frame(1)
    first_hit_frame = _frame(2)
    threshold_frame = _frame(3)
    threshold_detection = _detection(confidence=0.96)

    async def scenario() -> tuple[Emission, ...] | None:
        event_loop_thread = threading.get_ident()
        executor = ThreadPoolExecutor(max_workers=1)
        ticker = asyncio.create_task(_keep_restricted_event_loop_responsive())
        try:
            await operator.setup(
                ComponentContext(
                    pipeline_id="detection",
                    node_id="trigger",
                    shutdown_event=asyncio.Event(),
                    metadata={"thread_executor": executor},
                )
            )
            assert (
                await operator.process(_envelope(1, 0, frame=empty_frame)) is None
            )
            assert (
                await operator.process(
                    _envelope(2, 100, _detection(), frame=first_hit_frame)
                )
                is None
            )
            assert render_calls == []
            result = await operator.process(
                _envelope(
                    3,
                    200,
                    threshold_detection,
                    frame=threshold_frame,
                )
            )
            assert render_calls[0][0] != event_loop_thread
            return result
        finally:
            ticker.cancel()
            await asyncio.gather(ticker, return_exceptions=True)
            await operator.stop()
            executor.shutdown(wait=True, cancel_futures=True)

    result = asyncio.run(scenario())
    _, alert = _single_alert(result)

    assert len(render_calls) == 1
    _, rendered_frame, rendered_detections, quality = render_calls[0]
    assert rendered_frame is threshold_frame
    assert rendered_detections == (threshold_detection,)
    assert quality == 73
    assert alert.image is encoded
    health = asyncio.run(operator.health())
    assert health.healthy is True
    assert "alert_images_encoded=1" in health.detail
    assert "alert_images_attached=1" in health.detail


def test_multiple_alerts_from_one_frame_share_one_union_image(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    helmet = _detection("No-Helmet", confidence=0.91)
    vest = _detection("No-Vest", confidence=0.88)
    threshold_frame = _frame(4)
    encoded = EncodedImage(b"shared-jpeg", "image/jpeg", 4, 4)
    render_calls: list[tuple[ImageFrame, tuple[Detection, ...]]] = []

    def render(
        frame: ImageFrame,
        detections: tuple[Detection, ...],
        *,
        jpeg_quality: int,
    ) -> EncodedImage:
        assert jpeg_quality == 85
        render_calls.append((frame, tuple(detections)))
        return encoded

    async def run_inline(
        function: Any,
        *args: Any,
        executor: Any = None,
        **kwargs: Any,
    ) -> Any:
        del executor
        return function(*args, **kwargs)

    monkeypatch.setattr(detection_rules, "render_annotated_jpeg", render)
    monkeypatch.setattr(detection_rules, "run_blocking", run_inline)
    operator = DetectionTriggerOperator(
        "trigger",
        {
            "time_source": "received",
            "alert_image": {"enabled": True},
            "rules": [
                {
                    "id": "helmet",
                    "labels": ["No-Helmet"],
                    "min_hits": 1,
                    "window_ms": 100,
                },
                {
                    "id": "vest",
                    "labels": ["No-Vest"],
                    "min_hits": 1,
                    "window_ms": 100,
                },
            ],
        },
    )

    result = _process(
        operator,
        _envelope(1, 0, helmet, vest, frame=threshold_frame),
    )

    assert result is not None
    assert render_calls == [(threshold_frame, (helmet, vest))]
    assert [item.envelope.payload.rule_id for item in result] == ["helmet", "vest"]
    assert all(item.envelope.payload.image is encoded for item in result)
    health = asyncio.run(operator.health())
    assert "alert_images_encoded=1" in health.detail
    assert "alert_images_attached=2" in health.detail


def test_missing_threshold_frame_is_fail_soft_and_commits_cooldown() -> None:
    operator = _operator(
        min_hits=1,
        cooldown_ms=500,
        alert_image={"enabled": True},
    )

    _, alert = _single_alert(_process(operator, _envelope(1, 0, _detection())))
    assert alert.image is None
    # The alert state is committed even though its evidence image is missing.
    assert _process(operator, _envelope(1, 100, _detection())) is None
    assert _process(operator, _envelope(2, 100, _detection())) is None

    health = asyncio.run(operator.health())
    assert health.healthy is False
    assert "alerts_emitted=1" in health.detail
    assert "duplicate_frames=1" in health.detail
    assert "cooldown_suppressed=1" in health.detail
    assert "alert_image_missing_frames=1" in health.detail
    assert "alert_images_encoded=0" in health.detail


def test_renderer_failure_is_fail_soft_and_commits_cooldown(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    render_calls = 0

    def fail_render(*args: Any, **kwargs: Any) -> EncodedImage:
        nonlocal render_calls
        render_calls += 1
        raise ValueError("bad packed frame")

    async def run_inline(
        function: Any,
        *args: Any,
        executor: Any = None,
        **kwargs: Any,
    ) -> Any:
        del executor
        return function(*args, **kwargs)

    monkeypatch.setattr(detection_rules, "render_annotated_jpeg", fail_render)
    monkeypatch.setattr(detection_rules, "run_blocking", run_inline)
    operator = _operator(
        min_hits=1,
        cooldown_ms=500,
        alert_image={"enabled": True},
    )
    frame = _frame(5)

    _, alert = _single_alert(
        _process(operator, _envelope(1, 0, _detection(), frame=frame))
    )
    assert alert.image is None
    assert render_calls == 1
    assert _process(operator, _envelope(1, 100, _detection(), frame=frame)) is None
    assert _process(operator, _envelope(2, 100, _detection(), frame=frame)) is None

    health = asyncio.run(operator.health())
    assert health.healthy is False
    assert "alerts_emitted=1" in health.detail
    assert "duplicate_frames=1" in health.detail
    assert "cooldown_suppressed=1" in health.detail
    assert "alert_image_failures=1" in health.detail
    assert "alert_images_encoded=0" in health.detail


def test_pillow_dependency_is_checked_only_when_alert_images_are_enabled(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    checks = 0

    def missing_pillow() -> None:
        nonlocal checks
        checks += 1
        raise ImageAnnotationDependencyError("install the 'image' extra")

    monkeypatch.setattr(detection_rules, "ensure_annotation_support", missing_pillow)
    context = ComponentContext(
        pipeline_id="detection",
        node_id="trigger",
        shutdown_event=asyncio.Event(),
    )

    asyncio.run(_operator(min_hits=1).setup(context))
    assert checks == 0

    enabled = _operator(
        min_hits=1,
        alert_image={"enabled": True},
    )
    with pytest.raises(ImageAnnotationDependencyError, match="image.*extra"):
        asyncio.run(enabled.setup(context))
    assert checks == 1


def test_time_source_is_explicit_and_captured_requires_a_timestamp() -> None:
    captured = _operator(min_hits=2, window_ms=10, time_source="captured")
    with pytest.raises(ValueError, match="requires captured_at_ns"):
        _process(captured, _envelope(1, 0, _detection()))

    auto = _operator(min_hits=2, window_ms=10, time_source="auto")
    first = _envelope(1, 0, _detection(), captured=True)
    second = Envelope(
        payload=first.payload,
        schema_name="DetectionResult",
        source_id="camera-a",
        sequence=2,
        captured_at_ns=20 * NS_PER_MS,
        received_at_ns=5 * NS_PER_MS,
    )
    assert _process(auto, first) is None
    # auto prefers capture time, so the first hit is outside this 10 ms window.
    assert _process(auto, second) is None

    received = _operator(min_hits=2, window_ms=10, time_source="received")
    assert _process(received, first) is None
    _single_alert(_process(received, second))


def test_wrong_payload_and_out_of_order_frames_fail_closed() -> None:
    operator = _operator(min_hits=2)
    with pytest.raises(TypeError, match="expected DetectionResult"):
        _process(
            operator,
            Envelope(
                "not-a-detection",
                source_id="camera-a",
                sequence=1,
                received_at_ns=0,
            ),
        )

    assert _process(operator, _envelope(1, 100, _detection())) is None
    assert _process(operator, _envelope(2, 99, _detection())) is None
    assert "stale_frames=1" in asyncio.run(operator.health()).detail
