"""``CapacityCapPolicy`` — AZ-293 per-flight 64 GiB cap enforcement.

After every per-segment rotation the writer (AZ-291) invokes this hook
with the index of the just-closed segment. The policy walks the flight
directory, sums on-disk segment sizes, and unlinks the oldest CLOSED
segment until the total falls back under cap.

For each drop, a ``kind="segment_rollover"`` ``FdrRecord`` is enqueued
on the shared FdrClient. The currently-open segment is never dropped,
nor is the segment containing ``flight_header`` (segment 0) — if the
only droppable segment is segment 0 the policy logs a hard ERROR +
GCS alert (``fdr.cap_misconfigured``) and lets the flight continue in
degraded mode (operator action required).
"""

from __future__ import annotations

from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path

from gps_denied_onboard.components.c13_fdr.writer import FileFdrWriter
from gps_denied_onboard.fdr_client.client import FdrClient
from gps_denied_onboard.fdr_client.records import OVERRUN_PRODUCER_ID, FdrRecord
from gps_denied_onboard.logging import get_logger

__all__ = ["CapacityCapPolicy"]

_SEGMENT_ROLLOVER_KIND = "segment_rollover"
_CAP_MISCONFIG_KIND = "fdr.cap_misconfigured"
_CAP_DROP_KIND = "fdr.cap_drop"
_CAP_UNLINK_FAILED_KIND = "fdr.cap_unlink_failed"


def _iso_now() -> str:
    return datetime.now(tz=timezone.utc).isoformat()


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


class CapacityCapPolicy:
    """Per-flight directory-size cap (AZ-293 / E-C13)."""

    def __init__(
        self,
        cap_bytes: int,
        fdr_client: FdrClient,
        gcs_alert: Callable[[str], None],
    ) -> None:
        if not isinstance(cap_bytes, int) or isinstance(cap_bytes, bool):
            raise ValueError(f"cap_bytes must be a non-bool int; got {cap_bytes!r}")
        if cap_bytes < 1024:
            raise ValueError(
                f"cap_bytes must be >= 1024 (1 KiB minimum for tests); got {cap_bytes}"
            )
        if cap_bytes > 2**40:
            raise ValueError(f"cap_bytes must be <= {2**40} (1 TiB sanity bound); got {cap_bytes}")
        self._cap_bytes = cap_bytes
        self._fdr_client = fdr_client
        self._gcs_alert = gcs_alert
        self._alerted_for_misconfig = False
        self._log = get_logger("c13_fdr.cap_policy")

    @property
    def cap_bytes(self) -> int:
        return self._cap_bytes

    def __call__(self, writer: FileFdrWriter, just_rotated_from: int) -> None:
        """Post-rotation hook entry-point.

        ``just_rotated_from`` is the index of the segment that was open
        prior to the rotation (it has just been closed + fsync'd).
        """
        del just_rotated_from  # only used for diagnostic logs by AZ-291
        total = self._directory_size(writer)
        # Per AZ-293 AC-2 loop until under cap.
        while total > self._cap_bytes:
            closed = writer.list_closed_segments()
            if not closed:
                # The currently-open segment alone is over cap; we
                # cannot drop it. Fall through to the misconfigured
                # path.
                self._cap_misconfigured(writer, total)
                return
            # The oldest segment may BE segment 0 — which contains the
            # flight_header. We never drop segment 0 unless the operator
            # has explicitly configured ``allow_drop_segment_zero`` (no
            # such switch exists; AC-6 requires no opt-out). So if the
            # only droppable segment is 0, we hard-fail.
            oldest_index, oldest_path = closed[0]
            if oldest_index == 0 and len(closed) == 1:
                self._cap_misconfigured(writer, total)
                return
            if oldest_index == 0:
                # There are other segments to drop first. Skip segment 0
                # and drop the next-oldest instead.
                oldest_index, oldest_path = closed[1]
            freed = _on_disk_size(oldest_path)
            try:
                oldest_path.unlink()
            except OSError as exc:
                self._log.warning(
                    f"{_CAP_UNLINK_FAILED_KIND}: {oldest_path.name} ({exc})",
                    extra={
                        "kind": _CAP_UNLINK_FAILED_KIND,
                        "kv": {"path": str(oldest_path), "error": repr(exc)},
                    },
                )
                # Continue to next-oldest so we don't loop forever on a
                # missing inode (operator interference / Risk 2).
                total -= freed
                continue
            writer.increment_rollover_count_for_cap()
            total = self._directory_size(writer)
            self._emit_segment_rollover(
                old_segment=oldest_index,
                new_segment=writer.segments_written(),
                total_bytes_after=total,
            )
            self._log.info(
                f"{_CAP_DROP_KIND}: dropped segment {oldest_index}, total now {total}",
                extra={
                    "kind": _CAP_DROP_KIND,
                    "kv": {
                        "old_segment": oldest_index,
                        "new_segment": writer.segments_written(),
                        "total_bytes_after": total,
                    },
                },
            )

    def _directory_size(self, writer: FileFdrWriter) -> int:
        closed_size = sum(_on_disk_size(p) for _idx, p in writer.list_closed_segments())
        return closed_size + writer.current_segment_bytes()

    def _emit_segment_rollover(
        self, *, old_segment: int, new_segment: int, total_bytes_after: int
    ) -> None:
        record = FdrRecord(
            schema_version=1,
            ts=_iso_now(),
            producer_id=OVERRUN_PRODUCER_ID,
            kind=_SEGMENT_ROLLOVER_KIND,
            payload={
                "old_segment": old_segment,
                "new_segment": new_segment,
                "total_bytes_after": total_bytes_after,
            },
        )
        # Producer-side overrun policy (AZ-274) handles full-buffer
        # cases — the cap policy itself does not block here.
        self._fdr_client.enqueue(record)

    def _cap_misconfigured(self, writer: FileFdrWriter, observed_total: int) -> None:
        self._log.error(
            f"{_CAP_MISCONFIG_KIND}: cap={self._cap_bytes} bytes but "
            f"on-disk total={observed_total} bytes and no droppable segment exists",
            extra={
                "kind": _CAP_MISCONFIG_KIND,
                "kv": {
                    "cap_bytes": self._cap_bytes,
                    "observed_total": observed_total,
                    "current_segment_bytes": writer.current_segment_bytes(),
                },
            },
        )
        if not self._alerted_for_misconfig:
            self._alerted_for_misconfig = True
            try:
                self._gcs_alert(
                    f"FDR cap misconfigured: cap={self._cap_bytes} bytes "
                    f"< current single-segment size; flight degraded"
                )
            except Exception as exc:
                self._log.error(
                    "fdr.gcs_alert_failed",
                    extra={
                        "kind": "fdr.gcs_alert_failed",
                        "kv": {"on": "cap_misconfig", "error": repr(exc)},
                    },
                )
