"""``VideoFileFrameSource`` — replay-only file decoder (AZ-398).

Streams an MP4 / MKV / AVI file frame-by-frame via OpenCV's
:class:`cv2.VideoCapture`. Each emitted :class:`NavCameraFrame`
carries:

- ``frame_id`` — a strictly-increasing counter starting at 0.
- ``timestamp`` — UTC wall-clock at decode time (from the injected
  :class:`Clock`); the file's own pts is NOT used for this field
  because replay deterministically remaps it.
- ``image`` — the decoded BGR ``numpy.ndarray`` (OpenCV native order).
- ``metadata["monotonic_ns"]`` — the injected :class:`Clock`'s
  ``monotonic_ns()`` at decode time. This is the value AC-2 asserts
  non-decreasing.
- ``metadata["source_pts_ns"]`` — the file's per-frame PTS in ns (the
  ``CAP_PROP_POS_MSEC`` reading × 1e6) for downstream determinism.

Gated by ``BUILD_VIDEO_FILE_FRAME_SOURCE`` (Invariant 9). The check is
performed at constructor entry — a Tier-0 build that imports this
module by accident still raises ``FrameSourceConfigError`` cleanly
without attempting an OpenCV import.
"""

from __future__ import annotations

import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any

from gps_denied_onboard.frame_source.errors import (
    FrameSourceConfigError,
    FrameSourceError,
)

if TYPE_CHECKING:
    from gps_denied_onboard._types.nav import NavCameraFrame
    from gps_denied_onboard.clock import Clock


_BUILD_FLAG = "BUILD_VIDEO_FILE_FRAME_SOURCE"

_logger = logging.getLogger(__name__)


def _build_flag_on() -> bool:
    """``ON`` / ``1`` / ``true`` / ``yes`` (case-insensitive) → ``True``."""
    raw = os.environ.get(_BUILD_FLAG, "")
    return raw.strip().lower() in {"on", "1", "true", "yes"}


class VideoFileFrameSource:
    """Replay :class:`FrameSource` strategy backed by ``cv2.VideoCapture``.

    Stream-decodes a video file; per-frame decode is amortised by
    OpenCV's internal buffer. The strategy preserves the file's frame
    order — there is no seek, no random-access path; this keeps
    replay deterministic (Invariant 10).

    Constructor parameters:

    - ``path`` — filesystem path to an MP4/MKV/AVI (existence checked
      at construction).
    - ``camera_calibration_id`` — string identifier propagated into
      every emitted :class:`NavCameraFrame` so downstream consumers
      (C1, C3, C4) load the correct intrinsics for the recording.
    - ``clock`` — injected :class:`Clock`; the strategy reads
      ``clock.monotonic_ns()`` per emitted frame for the
      ``metadata["monotonic_ns"]`` ordering field.
    """

    __slots__ = (
        "_path",
        "_camera_calibration_id",
        "_clock",
        "_capture",
        "_frame_counter",
        "_last_monotonic_ns",
        "_closed",
        "_eos_returned",
    )

    def __init__(
        self,
        *,
        path: Path | str,
        camera_calibration_id: str,
        clock: "Clock",
    ) -> None:
        if not _build_flag_on():
            raise FrameSourceConfigError(
                f"{_BUILD_FLAG} is OFF in this binary; "
                "VideoFileFrameSource is unavailable. Rebuild with the "
                "flag set to ON in the replay binary's Dockerfile."
            )
        resolved = Path(path)
        if not resolved.is_file():
            raise FrameSourceConfigError(
                f"VideoFileFrameSource: path {resolved!s} does not exist "
                "or is not a regular file."
            )
        try:
            import cv2 as _cv2
        except ImportError as exc:
            raise FrameSourceConfigError(
                "VideoFileFrameSource requires opencv-python; not "
                "importable in this binary."
            ) from exc
        capture = _cv2.VideoCapture(str(resolved))
        if not capture.isOpened():
            capture.release()
            raise FrameSourceConfigError(
                f"VideoFileFrameSource: cv2.VideoCapture could not open "
                f"{resolved!s} (unsupported codec or corrupt header)."
            )
        self._path = resolved
        self._camera_calibration_id = camera_calibration_id
        self._clock = clock
        self._capture = capture
        self._frame_counter = 0
        self._last_monotonic_ns = -1
        self._closed = False
        self._eos_returned = False

    def next_frame(self) -> "NavCameraFrame | None":
        from gps_denied_onboard._types.nav import NavCameraFrame

        if self._closed or self._eos_returned:
            return None
        try:
            import cv2 as _cv2
        except ImportError as exc:  # pragma: no cover — established at __init__.
            raise FrameSourceError(
                "VideoFileFrameSource: opencv-python disappeared between "
                "construction and next_frame()"
            ) from exc
        ok, image = self._capture.read()
        if not ok:
            self._eos_returned = True
            return None
        if image is None:
            # OpenCV's read() returning ok=True with image=None signals a
            # decoder-internal failure for the current frame; treat as a
            # transient error per Invariant 4 rather than silently
            # advancing.
            raise FrameSourceError(
                f"VideoFileFrameSource: video decode failed at frame "
                f"{self._frame_counter} (cv2.VideoCapture.read returned "
                "ok=True with image=None)"
            )
        monotonic_ns = self._clock.monotonic_ns()
        if monotonic_ns < self._last_monotonic_ns:
            raise FrameSourceError(
                f"VideoFileFrameSource: clock went backwards at frame "
                f"{self._frame_counter}: {monotonic_ns} ns followed "
                f"{self._last_monotonic_ns} ns (Invariant 3)"
            )
        pos_msec = float(self._capture.get(_cv2.CAP_PROP_POS_MSEC))
        source_pts_ns = int(pos_msec * 1_000_000)
        timestamp = datetime.fromtimestamp(
            self._clock.time_ns() / 1e9, tz=timezone.utc
        )
        metadata: dict[str, Any] = {
            "monotonic_ns": monotonic_ns,
            "source_pts_ns": source_pts_ns,
            "source": "video_file",
        }
        frame = NavCameraFrame(
            frame_id=self._frame_counter,
            timestamp=timestamp,
            image=image,
            camera_calibration_id=self._camera_calibration_id,
            metadata=metadata,
        )
        self._frame_counter += 1
        self._last_monotonic_ns = monotonic_ns
        return frame

    def close(self) -> None:
        if self._closed:
            _logger.debug(
                "VideoFileFrameSource(%s) close called twice; no-op",
                self._path,
            )
            return
        self._closed = True
        try:
            self._capture.release()
        except Exception:  # pragma: no cover — defensive.
            # cv2.VideoCapture.release should never raise; if it does on
            # an exotic backend, we still want to flag the source as
            # closed so a second close() stays a no-op.
            _logger.exception(
                "VideoFileFrameSource(%s) cv2.release() raised", self._path
            )


__all__ = ["VideoFileFrameSource"]
