"""Typed adapter for the current cmvr-es RGB camera stream."""

from __future__ import annotations

import asyncio
import logging
import math
from collections.abc import Mapping
from dataclasses import dataclass
from importlib import import_module
from time import monotonic_ns, perf_counter
from typing import Any
from uuid import uuid4

from cmvr_edge_ai.contracts import CameraIntrinsics, ImageFrame
from cmvr_edge_ai.core.component import ComponentContext, Emission, Source
from cmvr_edge_ai.core.envelope import Envelope
from cmvr_edge_ai.transports.grpc import GrpcChannelPool

from .._context import endpoint_value, grpc_channel_settings, resolve_endpoint
from .errors import CmvrApiPackageError, CmvrServiceError


_LOGGER = logging.getLogger(__name__)


@dataclass(slots=True)
class _StreamStats:
    session_id: str
    opened_ns: int
    window_started_ns: int
    frames: int = 0
    received_bytes: int = 0
    key_frames: int = 0
    window_frames: int = 0
    window_bytes: int = 0
    window_key_frames: int = 0
    last_frame_ns: int | None = None
    last_remote_sequence: int | None = None
    codec: str = "unknown"
    width: int = 0
    height: int = 0


class CmvrCameraRgbStreamSource(Source):
    """Start a cmvr-es camera and subscribe to ``GetRGBImageStream``.

    cmvr-es currently reads one subscription request and then streams until the
    client cancels.  The request side remains open until shutdown so this
    adapter also remains compatible with a future true bidirectional contract.
    Every initial/reconnect session calls ``StartCamera`` first because the
    stream RPC starts encoding but does not open the physical camera.
    """

    def __init__(self, node_id: str, params: Mapping[str, Any]) -> None:
        allowed_params = {
            "endpoint",
            "device_id",
            "pixel_format",
            "reconnect",
            "reconnect_initial_s",
            "reconnect_max_s",
            "max_reconnect_attempts",
            "stream_log_interval_s",
        }
        unknown = set(params) - allowed_params
        if unknown:
            raise ValueError(
                "unknown camera source parameter(s): "
                + ", ".join(sorted(str(value) for value in unknown))
            )
        self._node_id = node_id
        self._endpoint_id = str(params.get("endpoint", "cmvr_es"))
        device_id = params.get("device_id")
        if not isinstance(device_id, str) or not device_id.strip():
            raise ValueError("camera source requires a non-empty device_id")
        pixel_format = params.get("pixel_format", "BGR8")
        if not isinstance(pixel_format, str) or not pixel_format.strip():
            raise ValueError("pixel_format must be a non-empty string")
        self._device_id = device_id.strip()
        self._pixel_format = pixel_format.strip()
        self._reconnect = _strict_bool(params, "reconnect", default=True)
        self._reconnect_initial_s = _positive_float(
            params, "reconnect_initial_s", default=0.5
        )
        self._reconnect_max_s = _positive_float(params, "reconnect_max_s", default=10.0)
        if self._reconnect_max_s < self._reconnect_initial_s:
            raise ValueError("reconnect_max_s must be >= reconnect_initial_s")
        max_attempts = params.get("max_reconnect_attempts")
        if max_attempts is None:
            self._max_reconnect_attempts: int | None = None
        elif isinstance(max_attempts, bool) or not isinstance(max_attempts, int):
            raise ValueError("max_reconnect_attempts must be an integer or null")
        elif max_attempts < 0:
            raise ValueError("max_reconnect_attempts must not be negative")
        else:
            self._max_reconnect_attempts = max_attempts
        self._stream_log_interval_s = _positive_float(
            params,
            "stream_log_interval_s",
            default=30.0,
        )
        self._pool: GrpcChannelPool | None = None
        self._stub: Any = None
        self._start_request: Any = None
        self._stream_request: Any = None
        self._call: Any = None
        self._shutdown_event: asyncio.Event | None = None
        self._rpc_timeout_s: float | None = None
        self._active_session_id: str | None = None
        self._total_frames = 0
        self._total_bytes = 0
        self._stopped = False

    async def setup(self, context: ComponentContext) -> None:
        endpoint = resolve_endpoint(context, self._endpoint_id)
        pool = context.metadata.get("grpc_channel_pool")
        if not isinstance(pool, GrpcChannelPool):
            raise TypeError("component context is missing GrpcChannelPool")
        self._pool = pool
        channel = await pool.get(self._endpoint_id, grpc_channel_settings(endpoint))
        self._shutdown_event = context.shutdown_event
        self._rpc_timeout_s = float(endpoint_value(endpoint, "timeout_s", 5.0))

        try:
            common_pb2 = import_module("cmvr.api.common_pb2")
            camera_command_pb2 = import_module("cmvr.api.camera_command_pb2")
            camera_service_pb2_grpc = import_module("cmvr.api.camera_service_pb2_grpc")
        except ImportError as exc:
            raise CmvrApiPackageError(
                "cmvr Python API bindings are missing; run "
                "scripts/generate_cmvr_stubs.py or install the cmvr-api wheel"
            ) from exc

        start_header = common_pb2.CommandHeader.Request(device_id=self._device_id)
        stream_header = common_pb2.CommandHeader.Request(device_id=self._device_id)
        self._start_request = camera_command_pb2.StartCameraCommand.Request(
            header=start_header
        )
        self._stream_request = camera_command_pb2.GetRGBImageStreamCommand.Request(
            header=stream_header
        )
        self._stub = camera_service_pb2_grpc.CameraServiceStub(channel)
        _LOGGER.info(
            "camera source configured node=%s endpoint=%s device=%s "
            "rpc_timeout_s=%.3f reconnect=%s reconnect_initial_s=%.3f "
            "reconnect_max_s=%.3f stream_log_interval_s=%.3f",
            self._node_id,
            self._endpoint_id,
            self._device_id,
            self._rpc_timeout_s,
            self._reconnect,
            self._reconnect_initial_s,
            self._reconnect_max_s,
            self._stream_log_interval_s,
        )

    async def messages(self):  # type: ignore[no-untyped-def]
        if (
            self._stub is None
            or self._start_request is None
            or self._stream_request is None
            or self._shutdown_event is None
            or self._rpc_timeout_s is None
        ):
            raise RuntimeError("camera source has not been set up")
        shutdown_event = self._shutdown_event
        reconnect_attempts = 0
        reconnect_delay_s = self._reconnect_initial_s
        local_sequence = 0

        while not shutdown_event.is_set():
            stream_session_id = uuid4().hex
            self._active_session_id = stream_session_id
            stats: _StreamStats | None = None
            progress_task: asyncio.Task[None] | None = None
            progress_stop_event: asyncio.Event | None = None

            async def subscription_requests():  # type: ignore[no-untyped-def]
                yield self._stream_request
                await shutdown_event.wait()

            try:
                await self._start_camera(
                    stream_session_id,
                    reconnect_attempt=reconnect_attempts,
                )
                opened_ns = monotonic_ns()
                stats = _StreamStats(
                    session_id=stream_session_id,
                    opened_ns=opened_ns,
                    window_started_ns=opened_ns,
                )
                _LOGGER.info(
                    "camera stream opening node=%s endpoint=%s device=%s "
                    "session=%s reconnect_attempt=%s waiting_for_first_frame=true",
                    self._node_id,
                    self._endpoint_id,
                    self._device_id,
                    stream_session_id,
                    reconnect_attempts,
                )
                self._call = self._stub.GetRGBImageStream(subscription_requests())
                progress_stop_event = asyncio.Event()
                progress_task = asyncio.create_task(
                    self._log_stream_progress(stats, progress_stop_event),
                    name=f"{self._node_id}:camera-stream-progress",
                )
                received_in_session = False
                async for response in self._call:
                    if shutdown_event.is_set():
                        return
                    if not response.header.success:
                        _LOGGER.warning(
                            "camera stream feedback error node=%s endpoint=%s "
                            "device=%s session=%s error=%s",
                            self._node_id,
                            self._endpoint_id,
                            self._device_id,
                            stream_session_id,
                            response.header.error_message
                            or "cmvr-es camera stream failed",
                        )
                        raise CmvrServiceError(
                            response.header.error_message
                            or "cmvr-es camera stream failed"
                        )
                    frame = response.color_frame
                    intrinsics = response.intrinsics
                    frame_data = bytes(frame.data)
                    frame_received_ns = monotonic_ns()
                    remote_sequence = int(response.seq_no)
                    stats.frames += 1
                    stats.received_bytes += len(frame_data)
                    stats.key_frames += int(bool(frame.is_key_frame))
                    stats.window_frames += 1
                    stats.window_bytes += len(frame_data)
                    stats.window_key_frames += int(bool(frame.is_key_frame))
                    stats.last_frame_ns = frame_received_ns
                    stats.last_remote_sequence = remote_sequence
                    stats.codec = frame.codec or "none"
                    stats.width = int(frame.width)
                    stats.height = int(frame.height)
                    self._total_frames += 1
                    self._total_bytes += len(frame_data)
                    if not received_in_session:
                        _LOGGER.info(
                            "camera stream first frame node=%s endpoint=%s device=%s "
                            "session=%s reconnect_attempt=%s time_to_first_frame_ms=%.2f "
                            "remote_sequence=%s codec=%s width=%s height=%s bytes=%s "
                            "key_frame=%s",
                            self._node_id,
                            self._endpoint_id,
                            self._device_id,
                            stream_session_id,
                            reconnect_attempts,
                            (frame_received_ns - opened_ns) / 1_000_000,
                            remote_sequence,
                            stats.codec,
                            stats.width,
                            stats.height,
                            len(frame_data),
                            bool(frame.is_key_frame),
                        )
                    received_in_session = True
                    reconnect_attempts = 0
                    reconnect_delay_s = self._reconnect_initial_s
                    captured_at_ns = _protobuf_timestamp_ns(response.header.timestamp)
                    payload = ImageFrame(
                        data=frame_data,
                        width=stats.width,
                        height=stats.height,
                        pixel_format=self._pixel_format,
                        codec=stats.codec,
                        is_key_frame=bool(frame.is_key_frame),
                        intrinsics=CameraIntrinsics(
                            fx=float(intrinsics.fx),
                            fy=float(intrinsics.fy),
                            cx=float(intrinsics.cx),
                            cy=float(intrinsics.cy),
                            coefficients=tuple(
                                float(value) for value in intrinsics.coeffs
                            ),
                        ),
                    )
                    sequence = local_sequence
                    local_sequence += 1
                    yield Emission(
                        "frames",
                        Envelope(
                            payload=payload,
                            schema_name="ImageFrame",
                            schema_version=1,
                            source_id=self._device_id,
                            sequence=sequence,
                            captured_at_ns=captured_at_ns,
                            session_id=stream_session_id,
                            attributes={
                                "transport": "grpc",
                                "endpoint": self._endpoint_id,
                                "remote_sequence": remote_sequence,
                                "stream_session_id": stream_session_id,
                            },
                        ),
                    )
                if shutdown_event.is_set():
                    return
                detail = (
                    "cmvr-es camera stream ended"
                    if received_in_session
                    else "cmvr-es camera stream ended before the first frame"
                )
                raise CmvrServiceError(detail)
            except asyncio.CancelledError:
                _LOGGER.info(
                    "camera stream cancelled node=%s endpoint=%s device=%s "
                    "session=%s session_frames=%s",
                    self._node_id,
                    self._endpoint_id,
                    self._device_id,
                    stream_session_id,
                    stats.frames if stats is not None else 0,
                )
                raise
            except Exception as exc:
                if shutdown_event.is_set():
                    return
                if not self._reconnect:
                    _LOGGER.error(
                        "camera stream failed node=%s endpoint=%s device=%s "
                        "session=%s reconnect=false error_type=%s error=%s",
                        self._node_id,
                        self._endpoint_id,
                        self._device_id,
                        stream_session_id,
                        type(exc).__name__,
                        exc,
                    )
                    raise
                reconnect_attempts += 1
                if (
                    self._max_reconnect_attempts is not None
                    and reconnect_attempts > self._max_reconnect_attempts
                ):
                    _LOGGER.error(
                        "camera stream reconnect exhausted node=%s endpoint=%s "
                        "device=%s session=%s attempts=%s error_type=%s error=%s",
                        self._node_id,
                        self._endpoint_id,
                        self._device_id,
                        stream_session_id,
                        reconnect_attempts,
                        type(exc).__name__,
                        exc,
                    )
                    raise CmvrServiceError(
                        "cmvr-es camera stream exceeded max_reconnect_attempts"
                    ) from exc
                _LOGGER.warning(
                    "camera stream disconnected node=%s endpoint=%s device=%s "
                    "session=%s session_frames=%s attempt=%s retry_in_s=%.3f "
                    "error_type=%s error=%s",
                    self._node_id,
                    self._endpoint_id,
                    self._device_id,
                    stream_session_id,
                    stats.frames if stats is not None else 0,
                    reconnect_attempts,
                    reconnect_delay_s,
                    type(exc).__name__,
                    exc,
                )
                try:
                    await asyncio.wait_for(
                        shutdown_event.wait(), timeout=reconnect_delay_s
                    )
                    return
                except (asyncio.TimeoutError, TimeoutError):
                    reconnect_delay_s = min(
                        self._reconnect_max_s, reconnect_delay_s * 2.0
                    )
            finally:
                if progress_stop_event is not None:
                    progress_stop_event.set()
                if progress_task is not None:
                    progress_task.cancel()
                    await asyncio.gather(progress_task, return_exceptions=True)
                if self._call is not None:
                    self._call.cancel()
                    self._call = None
                if stats is not None:
                    closed_ns = monotonic_ns()
                    _LOGGER.info(
                        "camera stream closed node=%s endpoint=%s device=%s "
                        "session=%s session_frames=%s session_bytes=%s "
                        "uptime_s=%.3f first_frame_received=%s",
                        self._node_id,
                        self._endpoint_id,
                        self._device_id,
                        stream_session_id,
                        stats.frames,
                        stats.received_bytes,
                        max(0.0, (closed_ns - stats.opened_ns) / 1_000_000_000),
                        stats.frames > 0,
                    )
                self._active_session_id = None

    async def stop(self) -> None:
        if self._stopped:
            return
        self._stopped = True
        _LOGGER.info(
            "camera source stopping node=%s endpoint=%s device=%s "
            "active_session=%s total_frames=%s total_bytes=%s",
            self._node_id,
            self._endpoint_id,
            self._device_id,
            self._active_session_id or "none",
            self._total_frames,
            self._total_bytes,
        )
        if self._call is not None:
            self._call.cancel()
            self._call = None
        _LOGGER.info(
            "camera source stopped node=%s endpoint=%s device=%s "
            "total_frames=%s total_bytes=%s remote_camera_stopped=false",
            self._node_id,
            self._endpoint_id,
            self._device_id,
            self._total_frames,
            self._total_bytes,
        )

    async def _start_camera(
        self,
        session_id: str,
        *,
        reconnect_attempt: int,
    ) -> None:
        assert self._stub is not None
        assert self._start_request is not None
        assert self._rpc_timeout_s is not None
        reason = (
            "initial"
            if self._total_frames == 0 and reconnect_attempt == 0
            else "reconnect"
        )
        _LOGGER.info(
            "camera start requested node=%s endpoint=%s device=%s session=%s "
            "reason=%s reconnect_attempt=%s timeout_s=%.3f",
            self._node_id,
            self._endpoint_id,
            self._device_id,
            session_id,
            reason,
            reconnect_attempt,
            self._rpc_timeout_s,
        )
        started = perf_counter()
        try:
            response = await self._stub.StartCamera(
                self._start_request,
                timeout=self._rpc_timeout_s,
            )
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            _LOGGER.warning(
                "camera start failed node=%s endpoint=%s device=%s session=%s "
                "reason=%s error_type=%s error=%s",
                self._node_id,
                self._endpoint_id,
                self._device_id,
                session_id,
                reason,
                type(exc).__name__,
                exc,
            )
            raise

        feedback = getattr(response, "header", response)
        if not bool(getattr(feedback, "success", False)):
            detail = (
                getattr(feedback, "error_message", "")
                or "cmvr-es rejected StartCamera"
            )
            _LOGGER.warning(
                "camera start failed node=%s endpoint=%s device=%s session=%s "
                "reason=%s error_type=CmvrServiceError error=%s",
                self._node_id,
                self._endpoint_id,
                self._device_id,
                session_id,
                reason,
                detail,
            )
            raise CmvrServiceError(detail)

        _LOGGER.info(
            "camera start succeeded node=%s endpoint=%s device=%s session=%s "
            "reason=%s elapsed_ms=%.2f",
            self._node_id,
            self._endpoint_id,
            self._device_id,
            session_id,
            reason,
            (perf_counter() - started) * 1000.0,
        )

    async def _log_stream_progress(
        self,
        stats: _StreamStats,
        shutdown_event: asyncio.Event,
    ) -> None:
        while not shutdown_event.is_set():
            try:
                await asyncio.wait_for(
                    shutdown_event.wait(),
                    timeout=self._stream_log_interval_s,
                )
                return
            except (asyncio.TimeoutError, TimeoutError):
                now_ns = monotonic_ns()
                window_s = max(
                    (now_ns - stats.window_started_ns) / 1_000_000_000,
                    1e-9,
                )
                last_frame_age_s = (
                    -1.0
                    if stats.last_frame_ns is None
                    else max(0.0, (now_ns - stats.last_frame_ns) / 1_000_000_000)
                )
                _LOGGER.info(
                    "camera stream progress node=%s endpoint=%s device=%s "
                    "session=%s first_frame_received=%s total_frames=%s "
                    "window_frames=%s window_fps=%.2f window_bytes=%s "
                    "bitrate_kbps=%.2f window_key_frames=%s last_remote_sequence=%s "
                    "codec=%s width=%s height=%s last_frame_age_s=%.3f",
                    self._node_id,
                    self._endpoint_id,
                    self._device_id,
                    stats.session_id,
                    stats.frames > 0,
                    stats.frames,
                    stats.window_frames,
                    stats.window_frames / window_s,
                    stats.window_bytes,
                    stats.window_bytes * 8.0 / window_s / 1000.0,
                    stats.window_key_frames,
                    (
                        stats.last_remote_sequence
                        if stats.last_remote_sequence is not None
                        else "none"
                    ),
                    stats.codec,
                    stats.width,
                    stats.height,
                    last_frame_age_s,
                )
                stats.window_frames = 0
                stats.window_bytes = 0
                stats.window_key_frames = 0
                stats.window_started_ns = now_ns
                # Custom Event implementations used by integrations may signal
                # a timeout immediately. Always yield after a progress tick so
                # logging can never monopolize the application event loop.
                await asyncio.sleep(0.001)


def _protobuf_timestamp_ns(timestamp: Any) -> int | None:
    seconds = int(getattr(timestamp, "seconds", 0))
    nanos = int(getattr(timestamp, "nanos", 0))
    if seconds == 0 and nanos == 0:
        return None
    return seconds * 1_000_000_000 + nanos


def _strict_bool(params: Mapping[str, Any], name: str, *, default: bool) -> bool:
    value = params.get(name, default)
    if type(value) is not bool:
        raise ValueError(f"{name} must be a boolean")
    return value


def _positive_float(params: Mapping[str, Any], name: str, *, default: float) -> float:
    value = params.get(name, default)
    if isinstance(value, bool):
        raise ValueError(f"{name} must be a positive number")
    try:
        parsed = float(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"{name} must be a positive number") from exc
    if not math.isfinite(parsed) or parsed <= 0:
        raise ValueError(f"{name} must be a positive number")
    return parsed


__all__ = ["CmvrCameraRgbStreamSource"]
