"""Stateful temporal rules for normalized object-detection results."""

from __future__ import annotations

from collections import deque
from collections.abc import Mapping, Sequence
from concurrent.futures import Executor
from dataclasses import dataclass, field, replace
import logging
import math
from typing import Any, Literal, cast
from uuid import uuid4

from cmvr_edge_ai.contracts import (
    BoundingBox,
    Detection,
    DetectionAlert,
    DetectionResult,
    EncodedImage,
    ImageFrame,
)
from cmvr_edge_ai.core import ComponentContext, Emission, Envelope, HealthReport, Operator
from cmvr_edge_ai.workers import run_blocking

from .annotation import ensure_annotation_support, render_annotated_jpeg


TimeSource = Literal["auto", "captured", "received"]
RuleScope = Literal["source", "track"]
FrameKey = tuple[str, int]
StateKey = tuple[str, str, str]


_LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True, slots=True)
class DetectionTriggerRule:
    """Validated deployment rule used by :class:`DetectionTriggerOperator`."""

    rule_id: str
    labels: tuple[str, ...]
    min_hits: int
    window_ms: float
    cooldown_ms: float
    min_confidence: float
    scope: RuleScope

    @property
    def window_ns(self) -> int:
        return int(self.window_ms * 1_000_000)

    @property
    def cooldown_ns(self) -> int:
        return int(self.cooldown_ms * 1_000_000)


@dataclass(frozen=True, slots=True)
class AlertImageOptions:
    enabled: bool = False
    jpeg_quality: int = 85


@dataclass(frozen=True, slots=True)
class _Hit:
    timestamp_ns: int
    labels: tuple[str, ...]
    max_confidence: float


@dataclass(slots=True)
class _TriggerState:
    hits: deque[_Hit] = field(default_factory=deque)
    # Kept independently from ``hits`` so replaying the threshold frame cannot
    # immediately retrigger a zero-cooldown rule after its hit window resets.
    seen_frames: dict[FrameKey, int] = field(default_factory=dict)
    cooldown_until_ns: int | None = None
    last_observed_ns: int | None = None


class DetectionTriggerOperator(Operator):
    """Emit alerts after distinct frames satisfy temporal detection rules.

    A hit is one matching *frame* for one rule and scope, regardless of how
    many matching boxes that frame contains.  Source-scoped rules aggregate a
    camera stream.  Track-scoped rules maintain independent windows per
    ``track_id`` and ignore detections for which no track ID is available.

    Once a rule triggers, its hit window is cleared.  Frames observed during
    cooldown are deliberately not accumulated; a fresh ``min_hits`` must be
    observed after cooldown expires.  Recent frame identities remain cached
    for one window to prevent duplicate/replayed envelopes from being counted.
    """

    _PARAM_KEYS = frozenset({"rules", "time_source", "alert_image"})
    _RULE_KEYS = frozenset(
        {
            "id",
            "labels",
            "min_hits",
            "window_ms",
            "cooldown_ms",
            "min_confidence",
            "scope",
        }
    )

    def __init__(self, node_id: str, params: Mapping[str, Any]) -> None:
        if not isinstance(params, Mapping):
            raise TypeError("detection trigger params must be a mapping")
        unknown = set(params) - self._PARAM_KEYS
        if unknown:
            raise ValueError(
                "unknown detection trigger parameter(s): "
                + ", ".join(sorted(str(item) for item in unknown))
            )

        self._node_id = node_id
        self._time_source = _parse_time_source(params.get("time_source", "auto"))
        self._alert_image = _parse_alert_image(params.get("alert_image"))
        self._rules = _parse_rules(params.get("rules"))
        self._rules_by_id = {rule.rule_id: rule for rule in self._rules}
        self._states: dict[StateKey, _TriggerState] = {}
        self._executor: Executor | None = None

        self._processed_frames = 0
        self._alerts_emitted = 0
        self._cooldown_suppressed = 0
        self._duplicate_frames = 0
        self._stale_frames = 0
        self._untracked_detections = 0
        self._invalid_detections = 0
        self._alert_images_encoded = 0
        self._alert_images_attached = 0
        self._alert_image_missing_frames = 0
        self._alert_image_failures = 0

    @property
    def rules(self) -> tuple[DetectionTriggerRule, ...]:
        """Return the immutable, validated rules (primarily for diagnostics)."""

        return self._rules

    @property
    def alert_image_enabled(self) -> bool:
        return self._alert_image.enabled

    async def setup(self, context: ComponentContext) -> None:
        executor = context.metadata.get("thread_executor")
        if executor is not None and not isinstance(executor, Executor):
            raise TypeError("component context thread_executor is not an Executor")
        self._executor = executor
        if self._alert_image.enabled:
            # Configuration errors should be discovered before camera traffic
            # starts, while individual corrupt frames remain fail-soft below.
            ensure_annotation_support()

    async def process(
        self,
        envelope: Envelope[Any],
        input_port: str = "input",
    ) -> tuple[Emission, ...] | None:
        del input_port
        result = envelope.payload
        if not isinstance(result, DetectionResult):
            raise TypeError(
                f"{self._node_id} expected DetectionResult, "
                f"got {type(result).__name__}"
            )

        timestamp_ns = self._timestamp_for(envelope)
        self._processed_frames += 1
        frame_key = (envelope.source_id, envelope.sequence)
        emissions: list[Emission] = []
        detections, invalid_count = _wire_safe_detections(result.detections)
        if invalid_count:
            self._invalid_detections += invalid_count
            _LOGGER.warning(
                "ignored invalid detections node=%s source=%s sequence=%s count=%s",
                self._node_id,
                envelope.source_id,
                envelope.sequence,
                invalid_count,
            )

        for rule in self._rules:
            matching = _matching_detections(detections, rule)
            if not matching:
                continue

            if rule.scope == "source":
                emission = self._observe(
                    rule=rule,
                    scope_id=envelope.source_id,
                    source_id=envelope.source_id,
                    frame_key=frame_key,
                    timestamp_ns=timestamp_ns,
                    detections=matching,
                    result=result,
                    envelope=envelope,
                )
                if emission is not None:
                    emissions.append(emission)
                continue

            by_track: dict[str, list[Detection]] = {}
            for detection in matching:
                if detection.track_id is None or not str(detection.track_id):
                    self._untracked_detections += 1
                    continue
                by_track.setdefault(str(detection.track_id), []).append(detection)
            for track_id, track_detections in by_track.items():
                emission = self._observe(
                    rule=rule,
                    scope_id=track_id,
                    source_id=envelope.source_id,
                    frame_key=frame_key,
                    timestamp_ns=timestamp_ns,
                    detections=tuple(track_detections),
                    result=result,
                    envelope=envelope,
                )
                if emission is not None:
                    emissions.append(emission)

        self._prune_inactive_states(timestamp_ns)
        if emissions and self._alert_image.enabled:
            emissions = await self._attach_alert_image(
                emissions,
                result.source_frame,
                envelope,
            )
        return tuple(emissions) if emissions else None

    async def stop(self) -> None:
        self._executor = None

    async def health(self) -> HealthReport:
        return HealthReport(
            not (
                self._invalid_detections > 0
                or (
                    self._alert_image.enabled
                    and (
                        self._alert_image_missing_frames > 0
                        or self._alert_image_failures > 0
                    )
                )
            ),
            " ".join(
                (
                    f"processed_frames={self._processed_frames}",
                    f"alerts_emitted={self._alerts_emitted}",
                    f"cooldown_suppressed={self._cooldown_suppressed}",
                    f"duplicate_frames={self._duplicate_frames}",
                    f"stale_frames={self._stale_frames}",
                    f"untracked_detections={self._untracked_detections}",
                    f"invalid_detections={self._invalid_detections}",
                    f"alert_images_encoded={self._alert_images_encoded}",
                    f"alert_images_attached={self._alert_images_attached}",
                    f"alert_image_missing_frames={self._alert_image_missing_frames}",
                    f"alert_image_failures={self._alert_image_failures}",
                    f"active_states={len(self._states)}",
                )
            ),
        )

    async def _attach_alert_image(
        self,
        emissions: list[Emission],
        frame: ImageFrame | None,
        source_envelope: Envelope[Any],
    ) -> list[Emission]:
        if frame is None:
            self._alert_image_missing_frames += 1
            _LOGGER.warning(
                "detection alert image omitted node=%s source=%s sequence=%s: "
                "DetectionResult has no source_frame",
                self._node_id,
                source_envelope.source_id,
                source_envelope.sequence,
            )
            return emissions

        # Multiple rules/tracks can trigger from the same threshold frame.  A
        # single union image bounds edge CPU and memory while retaining every
        # box relevant to at least one emitted alert.
        unique_detections: list[Detection] = []
        for emission in emissions:
            alert = emission.envelope.payload
            if not isinstance(alert, DetectionAlert):
                continue
            for detection in alert.detections:
                if detection not in unique_detections:
                    unique_detections.append(detection)

        try:
            image = await run_blocking(
                render_annotated_jpeg,
                frame,
                tuple(unique_detections),
                jpeg_quality=self._alert_image.jpeg_quality,
                executor=self._executor,
            )
            if not isinstance(image, EncodedImage):
                raise TypeError(
                    "alert image renderer returned "
                    f"{type(image).__name__}, expected EncodedImage"
                )
        except Exception:
            self._alert_image_failures += 1
            _LOGGER.warning(
                "detection alert image rendering failed node=%s source=%s "
                "sequence=%s; sending structured alert without image",
                self._node_id,
                source_envelope.source_id,
                source_envelope.sequence,
                exc_info=True,
            )
            return emissions

        updated: list[Emission] = []
        for emission in emissions:
            alert = emission.envelope.payload
            if not isinstance(alert, DetectionAlert):
                updated.append(emission)
                continue
            updated.append(
                Emission(
                    emission.port,
                    emission.envelope.with_payload(replace(alert, image=image)),
                )
            )
        self._alert_images_encoded += 1
        self._alert_images_attached += len(updated)
        return updated

    def _timestamp_for(self, envelope: Envelope[Any]) -> int:
        if self._time_source == "captured":
            if envelope.captured_at_ns is None:
                raise ValueError(
                    f"{self._node_id} requires captured_at_ns when "
                    "time_source='captured'"
                )
            return envelope.captured_at_ns
        if self._time_source == "received":
            return envelope.received_at_ns
        return (
            envelope.captured_at_ns
            if envelope.captured_at_ns is not None
            else envelope.received_at_ns
        )

    def _observe(
        self,
        *,
        rule: DetectionTriggerRule,
        scope_id: str,
        source_id: str,
        frame_key: FrameKey,
        timestamp_ns: int,
        detections: tuple[Detection, ...],
        result: DetectionResult,
        envelope: Envelope[Any],
    ) -> Emission | None:
        state_key = (rule.rule_id, source_id, scope_id)
        state = self._states.setdefault(state_key, _TriggerState())

        if state.last_observed_ns is not None and timestamp_ns < state.last_observed_ns:
            self._stale_frames += 1
            return None
        state.last_observed_ns = timestamp_ns
        self._evict_old_entries(state, rule, timestamp_ns)

        if frame_key in state.seen_frames:
            self._duplicate_frames += 1
            return None
        state.seen_frames[frame_key] = timestamp_ns

        if (
            state.cooldown_until_ns is not None
            and timestamp_ns < state.cooldown_until_ns
        ):
            self._cooldown_suppressed += 1
            return None
        if state.cooldown_until_ns is not None:
            state.cooldown_until_ns = None
            state.hits.clear()

        present_labels = {detection.label for detection in detections}
        hit_labels = tuple(label for label in rule.labels if label in present_labels)
        hit = _Hit(
            timestamp_ns=timestamp_ns,
            labels=hit_labels,
            max_confidence=max(detection.confidence for detection in detections),
        )
        state.hits.append(hit)
        if len(state.hits) < rule.min_hits:
            return None

        hits = tuple(state.hits)
        labels_seen = {label for item in hits for label in item.labels}
        alert = DetectionAlert(
            event_id=uuid4().hex,
            rule_id=rule.rule_id,
            model_id=result.model_id,
            model_name=result.model_name or result.model_id,
            labels=tuple(label for label in rule.labels if label in labels_seen),
            scope=rule.scope,
            scope_id=scope_id,
            hit_count=len(hits),
            window_ms=rule.window_ms,
            first_seen_ns=hits[0].timestamp_ns,
            last_seen_ns=hits[-1].timestamp_ns,
            triggered_at_ns=timestamp_ns,
            max_confidence=max(item.max_confidence for item in hits),
            # Only boxes from the threshold frame are attached.  Retaining all
            # boxes from every hit would multiply memory and HTTP payload size.
            detections=detections,
        )
        state.hits.clear()
        state.cooldown_until_ns = timestamp_ns + rule.cooldown_ns
        self._alerts_emitted += 1
        return Emission(
            "alerts",
            envelope.with_payload(
                alert,
                schema_name="DetectionAlert",
                schema_version=1,
            ),
        )

    @staticmethod
    def _evict_old_entries(
        state: _TriggerState,
        rule: DetectionTriggerRule,
        timestamp_ns: int,
    ) -> None:
        cutoff_ns = timestamp_ns - rule.window_ns
        # The window is inclusive at its lower boundary: an observation exactly
        # ``window_ms`` old remains eligible.
        while state.hits and state.hits[0].timestamp_ns < cutoff_ns:
            state.hits.popleft()
        expired_frames = [
            key for key, seen_ns in state.seen_frames.items() if seen_ns < cutoff_ns
        ]
        for key in expired_frames:
            del state.seen_frames[key]

    def _prune_inactive_states(self, timestamp_ns: int) -> None:
        expired: list[StateKey] = []
        for key, state in self._states.items():
            rule = self._rules_by_id[key[0]]
            if state.last_observed_ns is None or timestamp_ns < state.last_observed_ns:
                continue
            self._evict_old_entries(state, rule, timestamp_ns)
            if (
                state.cooldown_until_ns is not None
                and timestamp_ns >= state.cooldown_until_ns
            ):
                state.cooldown_until_ns = None
            if (
                not state.hits
                and not state.seen_frames
                and state.cooldown_until_ns is None
                and timestamp_ns - state.last_observed_ns > rule.window_ns
            ):
                expired.append(key)
        for key in expired:
            del self._states[key]


def _parse_time_source(value: Any) -> TimeSource:
    if not isinstance(value, str) or value not in {"auto", "captured", "received"}:
        raise ValueError("time_source must be one of: auto, captured, received")
    return cast(TimeSource, value)


def _parse_alert_image(value: Any) -> AlertImageOptions:
    if value is None:
        return AlertImageOptions()
    if not isinstance(value, Mapping):
        raise ValueError("alert_image must be a mapping")
    unknown = set(value) - {"enabled", "jpeg_quality"}
    if unknown:
        raise ValueError(
            "alert_image has unknown field(s): "
            + ", ".join(sorted(str(item) for item in unknown))
        )
    enabled = value.get("enabled", False)
    if not isinstance(enabled, bool):
        raise ValueError("alert_image.enabled must be a boolean")
    jpeg_quality = _strict_int(
        value.get("jpeg_quality", 85), "alert_image.jpeg_quality"
    )
    if not 1 <= jpeg_quality <= 95:
        raise ValueError("alert_image.jpeg_quality must be between 1 and 95")
    return AlertImageOptions(enabled=enabled, jpeg_quality=jpeg_quality)


def _parse_rules(value: Any) -> tuple[DetectionTriggerRule, ...]:
    if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
        raise ValueError("rules must be a non-empty list")
    if not value:
        raise ValueError("rules must be a non-empty list")

    parsed: list[DetectionTriggerRule] = []
    seen_ids: set[str] = set()
    for index, raw_rule in enumerate(value):
        path = f"rules[{index}]"
        if not isinstance(raw_rule, Mapping):
            raise ValueError(f"{path} must be a mapping")
        unknown = set(raw_rule) - DetectionTriggerOperator._RULE_KEYS
        if unknown:
            raise ValueError(
                f"{path} has unknown field(s): "
                + ", ".join(sorted(str(item) for item in unknown))
            )

        rule_id = _non_empty_string(raw_rule.get("id"), f"{path}.id")
        if rule_id in seen_ids:
            raise ValueError(f"duplicate detection rule id: {rule_id}")
        seen_ids.add(rule_id)

        labels_value = raw_rule.get("labels")
        if not isinstance(labels_value, Sequence) or isinstance(
            labels_value, (str, bytes)
        ):
            raise ValueError(f"{path}.labels must be a non-empty list")
        labels = tuple(
            _non_empty_string(label, f"{path}.labels[{label_index}]")
            for label_index, label in enumerate(labels_value)
        )
        if not labels:
            raise ValueError(f"{path}.labels must be a non-empty list")
        if len(set(labels)) != len(labels):
            raise ValueError(f"{path}.labels must not contain duplicates")

        min_hits = _strict_int(raw_rule.get("min_hits"), f"{path}.min_hits")
        if min_hits < 1:
            raise ValueError(f"{path}.min_hits must be at least 1")
        window_ms = _finite_number(raw_rule.get("window_ms"), f"{path}.window_ms")
        if window_ms <= 0 or int(window_ms * 1_000_000) < 1:
            raise ValueError(f"{path}.window_ms must be positive")
        cooldown_ms = _finite_number(
            raw_rule.get("cooldown_ms", 0.0), f"{path}.cooldown_ms"
        )
        if cooldown_ms < 0:
            raise ValueError(f"{path}.cooldown_ms must not be negative")
        min_confidence = _finite_number(
            raw_rule.get("min_confidence", 0.0), f"{path}.min_confidence"
        )
        if not 0.0 <= min_confidence <= 1.0:
            raise ValueError(f"{path}.min_confidence must be between 0 and 1")
        scope = raw_rule.get("scope", "source")
        if not isinstance(scope, str) or scope not in {"source", "track"}:
            raise ValueError(f"{path}.scope must be 'source' or 'track'")

        parsed.append(
            DetectionTriggerRule(
                rule_id=rule_id,
                labels=labels,
                min_hits=min_hits,
                window_ms=window_ms,
                cooldown_ms=cooldown_ms,
                min_confidence=min_confidence,
                scope=cast(RuleScope, scope),
            )
        )
    return tuple(parsed)


def _matching_detections(
    detections: tuple[Detection, ...], rule: DetectionTriggerRule
) -> tuple[Detection, ...]:
    labels = set(rule.labels)
    matching: list[Detection] = []
    for detection in detections:
        if not isinstance(detection, Detection):
            raise TypeError(
                "DetectionResult.detections must contain Detection instances"
            )
        if not math.isfinite(detection.confidence):
            raise ValueError("detection confidence must be finite")
        if detection.label in labels and detection.confidence >= rule.min_confidence:
            matching.append(detection)
    return tuple(matching)


def _wire_safe_detections(
    detections: tuple[Detection, ...],
) -> tuple[tuple[Detection, ...], int]:
    """Drop malformed items that could make an entire JSON alert unsendable."""

    valid: list[Detection] = []
    invalid_count = 0
    for detection in detections:
        if not isinstance(detection, Detection):
            raise TypeError(
                "DetectionResult.detections must contain Detection instances"
            )
        if not _wire_safe_detection(detection):
            invalid_count += 1
            continue
        valid.append(detection)
    return tuple(valid), invalid_count


def _wire_safe_detection(detection: Detection) -> bool:
    if not isinstance(detection.label, str) or not detection.label:
        return False
    if (
        isinstance(detection.confidence, bool)
        or not isinstance(detection.confidence, (int, float))
        or not math.isfinite(float(detection.confidence))
        or not 0.0 <= float(detection.confidence) <= 1.0
    ):
        return False
    if detection.track_id is not None and not isinstance(detection.track_id, str):
        return False
    if not isinstance(detection.box, BoundingBox):
        return False
    coordinates = (
        detection.box.x_min,
        detection.box.y_min,
        detection.box.x_max,
        detection.box.y_max,
    )
    if any(
        isinstance(value, bool)
        or not isinstance(value, (int, float))
        or not math.isfinite(float(value))
        for value in coordinates
    ):
        return False
    x_min, y_min, x_max, y_max = (float(value) for value in coordinates)
    return x_max > x_min and y_max > y_min


def _non_empty_string(value: Any, path: str) -> str:
    if not isinstance(value, str) or not value.strip():
        raise ValueError(f"{path} must be a non-empty string")
    return value.strip()


def _strict_int(value: Any, path: str) -> int:
    if isinstance(value, bool) or not isinstance(value, int):
        raise ValueError(f"{path} must be an integer")
    return value


def _finite_number(value: Any, path: str) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"{path} must be a number")
    parsed = float(value)
    if not math.isfinite(parsed):
        raise ValueError(f"{path} must be finite")
    return parsed


__all__ = ["AlertImageOptions", "DetectionTriggerOperator", "DetectionTriggerRule"]
