"""Model-independent detection pipeline operator."""

from __future__ import annotations

import logging
import math
from collections import Counter
from collections.abc import Mapping, Sequence
from concurrent.futures import Executor
from time import monotonic_ns, perf_counter
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from cmvr_edge_ai.contracts import Detection, DetectionResult, ImageFrame
from cmvr_edge_ai.core.component import (
    ComponentContext,
    Emission,
    HealthReport,
    Operator,
)
from cmvr_edge_ai.core.envelope import Envelope
from cmvr_edge_ai.workers import run_blocking

from .base import DetectionModel
from .registry import DetectionModelRegistry, DetectionModelSpec


_LOGGER = logging.getLogger(__name__)


class DetectorParams(BaseModel):
    """Strict deployment parameters shared by every detection backend."""

    model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=())

    model: str = Field(min_length=1)
    detect_labels: tuple[str, ...] | None = None
    confidence: float = Field(default=0.25, ge=0.0, le=1.0)
    label_confidence: dict[str, float] = Field(default_factory=dict)
    model_options: dict[str, Any] = Field(default_factory=dict)
    max_fps: float | None = Field(default=None, gt=0.0, le=1000.0)
    attach_frame: bool = Field(default=False, strict=True)
    inference_log_interval_s: float | None = Field(
        default=None,
        gt=0.0,
        le=3600.0,
    )

    @field_validator("model")
    @classmethod
    def normalize_model(cls, value: str) -> str:
        value = value.strip()
        if not value:
            raise ValueError("model must not be empty")
        return value

    @field_validator("inference_log_interval_s", mode="before")
    @classmethod
    def reject_boolean_log_interval(cls, value: Any) -> Any:
        if isinstance(value, bool):
            raise ValueError("inference_log_interval_s must be a number of seconds")
        return value

    @field_validator("detect_labels")
    @classmethod
    def validate_detect_labels(
        cls, value: tuple[str, ...] | None
    ) -> tuple[str, ...] | None:
        if value is None:
            return None
        if not value:
            raise ValueError("detect_labels must not be empty; omit it to detect all")
        normalized = tuple(label.strip() for label in value)
        if any(not label for label in normalized):
            raise ValueError("detect_labels must contain non-empty strings")
        if len(set(normalized)) != len(normalized):
            raise ValueError("detect_labels must not contain duplicates")
        return normalized

    @field_validator("label_confidence")
    @classmethod
    def validate_label_confidence(cls, value: dict[str, float]) -> dict[str, float]:
        normalized: dict[str, float] = {}
        for label, confidence in value.items():
            clean_label = label.strip()
            if not clean_label:
                raise ValueError("label_confidence keys must not be empty")
            parsed = float(confidence)
            if not 0.0 <= parsed <= 1.0:
                raise ValueError(
                    f"label_confidence.{clean_label} must be between 0 and 1"
                )
            normalized[clean_label] = parsed
        return normalized

    @model_validator(mode="after")
    def validate_label_threshold_scope(self) -> DetectorParams:
        if self.detect_labels is not None:
            unknown = set(self.label_confidence) - set(self.detect_labels)
            if unknown:
                raise ValueError(
                    "label_confidence contains labels not selected by detect_labels: "
                    + ", ".join(sorted(unknown))
                )
        return self


class DetectionOperator(Operator):
    """Load one registered model and normalize its per-frame detections."""

    def __init__(
        self,
        node_id: str,
        params: Mapping[str, Any],
        *,
        model_registry: DetectionModelRegistry,
    ) -> None:
        self._node_id = node_id
        try:
            self._params = DetectorParams.model_validate(dict(params))
        except Exception as exc:
            raise ValueError(f"invalid detection node parameters: {exc}") from exc

        self._spec = model_registry.resolve(self._params.model)
        selected = (
            self._spec.supported_labels
            if self._params.detect_labels is None
            else self._params.detect_labels
        )
        unsupported = set(selected) - set(self._spec.supported_labels)
        if unsupported:
            raise ValueError(
                f"model {self._spec.model_id!r} does not support label(s): "
                + ", ".join(sorted(unsupported))
            )
        invalid_thresholds = set(self._params.label_confidence) - set(
            self._spec.supported_labels
        )
        if invalid_thresholds:
            raise ValueError(
                f"model {self._spec.model_id!r} does not support threshold label(s): "
                + ", ".join(sorted(invalid_thresholds))
            )

        self._selected_labels = tuple(selected)
        self._selected_label_set = frozenset(selected)
        self._minimum_backend_confidence = min(
            (
                self._params.label_confidence.get(label, self._params.confidence)
                for label in self._selected_labels
            ),
            default=self._params.confidence,
        )
        self._model: DetectionModel = self._spec.factory(self._params.model_options)
        if not isinstance(self._model, DetectionModel):
            raise TypeError(
                f"model factory for {self._spec.model_id!r} returned "
                f"{type(self._model).__name__}, expected DetectionModel"
            )

        self._executor: Executor | None = None
        self._loaded = False
        self._closed = False
        self._last_started_ns: int | None = None
        self._processed = 0
        self._skipped_rate_limit = 0
        self._detections = 0
        self._last_inference_log_ns: int | None = None
        self._log_window_frames = 0
        self._log_window_detections = 0
        self._log_window_inference_ms = 0.0
        self._log_window_labels: Counter[str] = Counter()

    @property
    def model_spec(self) -> DetectionModelSpec:
        return self._spec

    @property
    def selected_labels(self) -> tuple[str, ...]:
        return self._selected_labels

    @property
    def attach_frame(self) -> bool:
        return self._params.attach_frame

    async def setup(self, context: ComponentContext) -> None:
        if self._closed:
            raise RuntimeError("detection operator is already closed")
        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
        load_started = perf_counter()
        await run_blocking(self._model.load, executor=self._executor)
        load_ms = (perf_counter() - load_started) * 1000.0
        self._loaded = True
        _LOGGER.info(
            "detection model loaded node=%s model=%s name=%s backend=%s "
            "labels=%s load_ms=%.2f",
            self._node_id,
            self._spec.model_id,
            self._spec.name,
            self._spec.backend,
            ",".join(self._selected_labels),
            load_ms,
        )

    async def process(
        self, envelope: Envelope[Any], input_port: str = "frames"
    ) -> Emission | None:
        del input_port
        if not self._loaded:
            raise RuntimeError("detection model has not been loaded")
        frame = envelope.payload
        if not isinstance(frame, ImageFrame):
            raise TypeError(
                f"{self._node_id} expected ImageFrame, got {type(frame).__name__}"
            )
        if frame.is_encoded:
            raise ValueError(
                f"{self._node_id} requires a decoded ImageFrame; got codec "
                f"{frame.codec!r}"
            )

        now = monotonic_ns()
        if self._params.max_fps is not None and self._last_started_ns is not None:
            minimum_interval_ns = int(1_000_000_000 / self._params.max_fps)
            if now - self._last_started_ns < minimum_interval_ns:
                self._skipped_rate_limit += 1
                return None
        self._last_started_ns = now

        started = perf_counter()
        predicted = await run_blocking(
            self._model.predict,
            frame,
            labels=self._selected_labels,
            confidence=self._minimum_backend_confidence,
            executor=self._executor,
        )
        inference_ms = (perf_counter() - started) * 1000.0
        detections = self._filter_detections(predicted)
        self._processed += 1
        self._detections += len(detections)
        self._record_inference_log(
            envelope,
            detections,
            inference_ms=inference_ms,
            now_ns=now,
        )

        result = DetectionResult(
            detections=detections,
            model_id=self._spec.model_id,
            inference_ms=inference_ms,
            model_name=self._spec.name,
            source_frame=frame if self._params.attach_frame else None,
        )
        output = envelope.with_payload(
            result,
            schema_name="DetectionResult",
            schema_version=1,
        ).with_attributes(
            detection_model_id=self._spec.model_id,
            detection_model_name=self._spec.name,
        )
        return Emission("detections", output)

    async def stop(self) -> None:
        if self._closed:
            return
        self._closed = True
        try:
            # close() is required to be idempotent and is also called after a
            # partially failed load so native allocations are not leaked.
            await run_blocking(self._model.close, executor=self._executor)
        finally:
            self._loaded = False

    async def health(self) -> HealthReport:
        return HealthReport(
            healthy=self._loaded,
            detail=(
                f"model={self._spec.model_id} loaded={self._loaded} "
                f"frames={self._processed} detections={self._detections} "
                f"rate_skipped={self._skipped_rate_limit}"
            ),
        )

    def _record_inference_log(
        self,
        envelope: Envelope[Any],
        detections: tuple[Detection, ...],
        *,
        inference_ms: float,
        now_ns: int,
    ) -> None:
        interval_s = self._params.inference_log_interval_s
        if interval_s is None:
            return

        self._log_window_frames += 1
        self._log_window_detections += len(detections)
        self._log_window_inference_ms += inference_ms
        self._log_window_labels.update(item.label for item in detections)

        interval_ns = int(interval_s * 1_000_000_000)
        if (
            self._last_inference_log_ns is not None
            and now_ns - self._last_inference_log_ns < interval_ns
        ):
            return

        hit_labels = ",".join(
            f"{label}:{count}"
            for label, count in sorted(self._log_window_labels.items())
        ) or "none"
        average_inference_ms = (
            self._log_window_inference_ms / self._log_window_frames
        )
        _LOGGER.info(
            "detection inference node=%s model=%s total_frames=%s "
            "window_frames=%s window_detections=%s hit_labels=%s "
            "avg_inference_ms=%.2f rate_skipped=%s source=%s sequence=%s",
            self._node_id,
            self._spec.model_id,
            self._processed,
            self._log_window_frames,
            self._log_window_detections,
            hit_labels,
            average_inference_ms,
            self._skipped_rate_limit,
            envelope.source_id,
            envelope.sequence,
        )
        self._last_inference_log_ns = now_ns
        self._log_window_frames = 0
        self._log_window_detections = 0
        self._log_window_inference_ms = 0.0
        self._log_window_labels.clear()

    def _filter_detections(
        self, predicted: Sequence[Detection]
    ) -> tuple[Detection, ...]:
        normalized: list[Detection] = []
        for detection in predicted:
            if not isinstance(detection, Detection):
                raise TypeError(
                    f"model {self._spec.model_id!r} returned "
                    f"{type(detection).__name__}, expected Detection"
                )
            if detection.label not in self._spec.supported_labels:
                raise ValueError(
                    f"model {self._spec.model_id!r} returned unregistered label "
                    f"{detection.label!r}"
                )
            if detection.label not in self._selected_label_set:
                continue
            if not math.isfinite(detection.confidence) or not (
                0.0 <= detection.confidence <= 1.0
            ):
                raise ValueError(
                    f"model returned invalid confidence {detection.confidence!r}"
                )
            threshold = self._params.label_confidence.get(
                detection.label, self._params.confidence
            )
            if detection.confidence >= threshold:
                normalized.append(detection)
        return tuple(normalized)


__all__ = ["DetectionOperator", "DetectorParams"]
