"""``MidFlightTileSnapshotSink`` — sidecar storage for F4 tile snapshots (AZ-294).

C6 / C11 producers call :py:meth:`MidFlightTileSnapshotSink.write_snapshot`
with the orthorectified JPEG bytes. The sink:

1. Validates JPEG size (``jpeg_max_bytes``) and ``tile_id`` regex.
2. Writes the JPEG to ``flight_root/<flight_id>/tiles/<tile_id>.jpg``
   atomically (temp file + ``fsync`` + ``rename``).
3. Enqueues a single ``kind="mid_flight_tile_snapshot"`` FdrRecord
   carrying the relative path + capture timestamp.
4. Enforces the per-flight tile cap (``tile_snapshot_cap_bytes``) by
   dropping the oldest tile if the cumulative size exceeds the cap;
   emits a ``kind="overrun"`` record per drop.

Thread-safe: many producer threads may call ``write_snapshot``
concurrently; an internal lock serialises the cap-check + drop +
record-enqueue sequence. The JPEG write itself is independent and
runs outside the lock so producers do not serialise on each other's
disk IO.
"""

from __future__ import annotations

import os
import re
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Final
from uuid import UUID

from gps_denied_onboard.components.c13_fdr.errors import (
    TileSnapshotInvalidIdError,
    TileSnapshotTooLargeError,
)
from gps_denied_onboard.config import TileSnapshotConfig
from gps_denied_onboard.fdr_client.client import FdrClient
from gps_denied_onboard.fdr_client.records import (
    OVERRUN_KIND,
    OVERRUN_PRODUCER_ID,
    FdrRecord,
)
from gps_denied_onboard.logging import get_logger

__all__ = ["MidFlightTileSnapshotSink"]

_TILE_ID_RE: Final[re.Pattern[str]] = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
_SNAPSHOT_KIND: Final[str] = "mid_flight_tile_snapshot"
_TILES_SUBDIR: Final[str] = "tiles"


def _iso(captured_at: datetime) -> str:
    if captured_at.tzinfo is None:
        captured_at = captured_at.replace(tzinfo=timezone.utc)
    return captured_at.astimezone(timezone.utc).isoformat()


def _on_disk_size(path: Path) -> int:
    try:
        return path.stat().st_size
    except OSError:
        return 0


class MidFlightTileSnapshotSink:
    """Sidecar writer for F4 mid-flight tile snapshots."""

    def __init__(
        self,
        flight_root: Path,
        flight_id: UUID,
        fdr_client: FdrClient,
        config: TileSnapshotConfig,
    ) -> None:
        self._flight_root = Path(flight_root)
        self._flight_id = flight_id
        self._fdr_client = fdr_client
        self._config = config
        self._flight_dir = self._flight_root / str(flight_id)
        self._tiles_dir = self._flight_dir / _TILES_SUBDIR
        self._lock = threading.Lock()
        self._log = get_logger("c13_fdr.tile_snapshot_sink")
        # In-memory cache of (captured_at_iso, tile_id, path) sorted by
        # captured_at ASC. Refreshed lazily from disk on cap-check entry
        # so an externally-deleted tile does not corrupt accounting
        # (matches AZ-293's stale-list refresh pattern).
        self._tile_index: list[tuple[str, str, Path]] = []
        self._tile_index_initialised = False

    @property
    def tiles_dir(self) -> Path:
        return self._tiles_dir

    def write_snapshot(
        self,
        tile_id: str,
        jpeg_bytes: bytes,
        captured_at: datetime,
        frame_id: int | None = None,
    ) -> Path:
        """Persist ``jpeg_bytes`` to the canonical sidecar path and emit a pointer record.

        Returns the absolute path of the on-disk sidecar file.
        """
        if not isinstance(jpeg_bytes, (bytes, bytearray)):
            raise TypeError(f"jpeg_bytes must be bytes; got {type(jpeg_bytes).__name__}")
        if len(jpeg_bytes) > self._config.jpeg_max_bytes:
            raise TileSnapshotTooLargeError(
                f"JPEG size {len(jpeg_bytes)} bytes exceeds jpeg_max_bytes "
                f"{self._config.jpeg_max_bytes}"
            )
        if not isinstance(tile_id, str) or not _TILE_ID_RE.match(tile_id):
            raise TileSnapshotInvalidIdError(
                f"tile_id {tile_id!r} does not match {_TILE_ID_RE.pattern!r}"
            )

        self._tiles_dir.mkdir(parents=True, exist_ok=True)
        canonical_path = self._tiles_dir / f"{tile_id}.jpg"
        # Atomic write: temp file + fsync + rename.
        tmp_path = canonical_path.with_suffix(canonical_path.suffix + ".tmp")
        with open(tmp_path, "wb") as fh:
            fh.write(bytes(jpeg_bytes))
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(tmp_path, canonical_path)

        captured_iso = _iso(captured_at)
        payload: dict[str, object] = {
            "snapshot_path": f"{_TILES_SUBDIR}/{tile_id}.jpg",
            "captured_at": captured_iso,
        }
        if frame_id is not None:
            payload["frame_id"] = int(frame_id)
        record = FdrRecord(
            schema_version=1,
            ts=datetime.now(tz=timezone.utc).isoformat(),
            producer_id=OVERRUN_PRODUCER_ID,
            kind=_SNAPSHOT_KIND,
            payload=payload,
        )
        self._fdr_client.enqueue(record)

        # Cap check + drop. Lock covers both index refresh and the drop
        # so concurrent writers cannot double-drop the same tile.
        with self._lock:
            self._refresh_index_if_needed()
            self._tile_index.append((captured_iso, tile_id, canonical_path))
            self._tile_index.sort(key=lambda entry: entry[0])
            self._evict_until_under_cap()
        self._log.info(
            f"fdr.tile_snapshot_written: {tile_id} ({len(jpeg_bytes)} B)",
            extra={
                "kind": "fdr.tile_snapshot_written",
                "kv": {"tile_id": tile_id, "size_bytes": len(jpeg_bytes)},
            },
        )
        return canonical_path

    def _refresh_index_if_needed(self) -> None:
        if self._tile_index_initialised:
            return
        self._tile_index_initialised = True
        if not self._tiles_dir.exists():
            return
        entries: list[tuple[str, str, Path]] = []
        for entry in self._tiles_dir.iterdir():
            if not entry.is_file() or entry.suffix != ".jpg":
                continue
            tile_id = entry.stem
            if not _TILE_ID_RE.match(tile_id):
                continue
            # Use the file mtime as a proxy for captured_at when this is a
            # pre-existing tile from a prior process (per AC-7). It is a
            # monotonic-enough ordering for oldest-first eviction.
            mtime_iso = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc).isoformat()
            entries.append((mtime_iso, tile_id, entry))
        entries.sort(key=lambda kv: kv[0])
        self._tile_index = entries

    def _evict_until_under_cap(self) -> None:
        cap = self._config.tile_snapshot_cap_bytes
        total = self._directory_size()
        while total > cap and self._tile_index:
            _captured_iso, tile_id, path = self._tile_index.pop(0)
            freed = _on_disk_size(path)
            try:
                path.unlink()
            except OSError as exc:
                self._log.warning(
                    f"fdr.tile_snapshot_unlink_failed: {path.name} ({exc})",
                    extra={
                        "kind": "fdr.tile_snapshot_unlink_failed",
                        "kv": {"tile_id": tile_id, "error": repr(exc)},
                    },
                )
                total -= freed
                continue
            self._emit_overrun(tile_id=tile_id)
            total = self._directory_size()
            self._log.warning(
                f"fdr.tile_snapshot_dropped: {tile_id} (freed {freed} B; total {total} B)",
                extra={
                    "kind": "fdr.tile_snapshot_dropped",
                    "kv": {
                        "tile_id": tile_id,
                        "size_bytes_freed": freed,
                        "cap_bytes_after": total,
                    },
                },
            )

    def _directory_size(self) -> int:
        return sum(_on_disk_size(p) for _ts, _tid, p in self._tile_index)

    def _emit_overrun(self, tile_id: str) -> None:
        # ``producer_id`` payload field per the contract carries the
        # ORIGINATING producer slug; the cap-driven drop is sink-side
        # so we report the sink's slug. Outer envelope is always
        # OVERRUN_PRODUCER_ID per AZ-272.
        record = FdrRecord(
            schema_version=1,
            ts=datetime.now(tz=timezone.utc).isoformat(),
            producer_id=OVERRUN_PRODUCER_ID,
            kind=OVERRUN_KIND,
            payload={
                "producer_id": "shared.tile_snapshot_sink",
                "dropped_count": 1,
            },
        )
        self._fdr_client.enqueue(record)
