"""AZ-291 — FileFdrWriter writer thread + segment lifecycle.

Covers AC-1..AC-8 + a fresh-flight_id helper used by every test.
"""

from __future__ import annotations

import errno
import os
import struct
import time
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from unittest import mock
from uuid import UUID, uuid4

import pytest

from gps_denied_onboard.components.c13_fdr import (
    FdrConcurrentWriterError,
    FileFdrWriter,
    FlightHeader,
)
from gps_denied_onboard.config import FdrWriterConfig
from gps_denied_onboard.fdr_client.client import FdrClient
from gps_denied_onboard.fdr_client.records import FdrRecord, parse

_LENGTH_PREFIX = struct.Struct("<I")


def _make_header(flight_id: UUID) -> FlightHeader:
    return FlightHeader(
        flight_id=flight_id,
        flight_started_at_iso=datetime.now(tz=timezone.utc).isoformat(),
        flight_started_at_monotonic_ns=time.monotonic_ns(),
        config_snapshot={"tier": 2},
        signing_key_rotation_event={},
        manifest_content_hashes={},
        build_info={"commit": "abc1234"},
    )


def _make_client(producer_id: str = "c1_vio", capacity: int = 256) -> FdrClient:
    return FdrClient(producer_id=producer_id, capacity=capacity, _emit_diag_log=False)


def _payload(i: int) -> FdrRecord:
    return FdrRecord(
        schema_version=1,
        ts=datetime.now(tz=timezone.utc).isoformat(),
        producer_id="c1_vio",
        kind="vio.tick",
        payload={
            "frame_id": i,
            "R": [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
            "t": [0, 0, 0],
            "P": [],
            "last_anchor_age_ms": 0,
        },
    )


def _read_records(path: Path) -> list[FdrRecord]:
    records: list[FdrRecord] = []
    data = path.read_bytes()
    offset = 0
    while offset < len(data):
        (length,) = _LENGTH_PREFIX.unpack_from(data, offset)
        offset += _LENGTH_PREFIX.size
        records.append(parse(data[offset : offset + length]))
        offset += length
    return records


def _collect_alerts() -> tuple[list[str], Any]:
    msgs: list[str] = []

    def alert(msg: str) -> None:
        msgs.append(msg)

    return msgs, alert


@pytest.fixture()
def flight_root(tmp_path: Path) -> Path:
    return tmp_path / "fdr"


@pytest.fixture()
def flight_id() -> UUID:
    return uuid4()


@pytest.fixture()
def base_config() -> FdrWriterConfig:
    return FdrWriterConfig(
        segment_size_bytes=64 * 1024 * 1024,
        batch_size=64,
        flight_cap_bytes=64 * 1024**3,
        debug_log_per_record=False,
    )


@pytest.fixture()
def writer(
    flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> Iterator[FileFdrWriter]:
    _alerts, alert_fn = _collect_alerts()
    client = _make_client()
    w = FileFdrWriter(
        flight_root=flight_root,
        flight_id=flight_id,
        config=base_config,
        fdr_clients=[client],
        gcs_alert=alert_fn,
    )
    yield w
    if not w._closed:
        w.stop()


def test_ac1_drain_all_registered_producers(
    flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> None:
    # Arrange
    clients = [_make_client(f"c{i}_test") for i in range(3)]
    _alerts, alert_fn = _collect_alerts()
    writer = FileFdrWriter(
        flight_root=flight_root,
        flight_id=flight_id,
        config=base_config,
        fdr_clients=clients,
        gcs_alert=alert_fn,
    )
    writer.start()
    writer.open_flight(_make_header(flight_id))
    for client in clients:
        for i in range(100):
            client.enqueue(_payload(i))

    # Act
    deadline = time.monotonic() + 5.0
    while time.monotonic() < deadline:
        if all(c._buffer_size() == 0 for c in clients):
            break
        time.sleep(0.01)
    footer = writer.close_flight()

    # Assert
    records = _read_records(writer.current_segment_path())
    vio_count = sum(1 for r in records if r.kind == "vio.tick")
    assert vio_count == 300
    assert records[0].kind == "flight_header"
    assert records[-1].kind == "flight_footer"
    assert footer.records_written == 302  # 300 + header + footer


def test_ac2_per_segment_rotation_at_size_cap(flight_root: Path, flight_id: UUID) -> None:
    # Arrange — small segment cap; the writer must rotate.
    config = FdrWriterConfig(segment_size_bytes=2048, batch_size=4, flight_cap_bytes=1024**3)
    _alerts, alert_fn = _collect_alerts()
    client = _make_client()
    writer = FileFdrWriter(
        flight_root=flight_root,
        flight_id=flight_id,
        config=config,
        fdr_clients=[client],
        gcs_alert=alert_fn,
    )
    writer.start()
    writer.open_flight(_make_header(flight_id))
    for i in range(40):
        client.enqueue(_payload(i))

    # Act
    deadline = time.monotonic() + 5.0
    while time.monotonic() < deadline and client._buffer_size() > 0:
        time.sleep(0.01)
    writer.close_flight()

    # Assert — at least two segment files exist.
    segs = sorted(writer.flight_dir.glob("segment-*.fdr"))
    assert len(segs) >= 2, f"expected >=2 segments, got {[p.name for p in segs]}"
    all_records: list[FdrRecord] = []
    for seg in segs:
        all_records.extend(_read_records(seg))
    vio = [r for r in all_records if r.kind == "vio.tick"]
    frame_ids = [r.payload["frame_id"] for r in vio]
    assert frame_ids == list(range(40))


def test_ac3_atomic_rotation_no_half_segment(flight_root: Path, flight_id: UUID) -> None:
    # Arrange
    config = FdrWriterConfig(segment_size_bytes=1024, batch_size=4, flight_cap_bytes=1024**3)
    _alerts, alert_fn = _collect_alerts()
    client = _make_client()
    writer = FileFdrWriter(
        flight_root=flight_root,
        flight_id=flight_id,
        config=config,
        fdr_clients=[client],
        gcs_alert=alert_fn,
    )
    writer.start()
    writer.open_flight(_make_header(flight_id))
    for i in range(20):
        client.enqueue(_payload(i))
    deadline = time.monotonic() + 5.0
    while time.monotonic() < deadline and client._buffer_size() > 0:
        time.sleep(0.01)

    # Act — abrupt stop (no close_flight).
    writer.stop()

    # Assert — every segment file parses cleanly.
    for seg in sorted(writer.flight_dir.glob("segment-*.fdr")):
        records = _read_records(seg)
        for r in records:
            assert r.schema_version >= 1


def test_ac4_concurrent_writer_blocked_by_filelock(
    flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> None:
    # Arrange
    _alerts, alert_fn = _collect_alerts()
    client_a = _make_client("c1_vio")
    writer_a = FileFdrWriter(
        flight_root=flight_root,
        flight_id=flight_id,
        config=base_config,
        fdr_clients=[client_a],
        gcs_alert=alert_fn,
    )
    writer_a.start()
    client_b = _make_client("c2_vpr")
    writer_b = FileFdrWriter(
        flight_root=flight_root,
        flight_id=uuid4(),
        config=base_config,
        fdr_clients=[client_b],
        gcs_alert=alert_fn,
    )

    # Act, Assert
    with pytest.raises(FdrConcurrentWriterError):
        writer_b.start()

    # Cleanup
    writer_a.stop()


def test_ac5_enospc_degrades_and_alerts(
    flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> None:
    # Arrange
    alerts, alert_fn = _collect_alerts()
    client = _make_client()
    writer = FileFdrWriter(
        flight_root=flight_root,
        flight_id=flight_id,
        config=base_config,
        fdr_clients=[client],
        gcs_alert=alert_fn,
    )
    writer.start()
    writer.open_flight(_make_header(flight_id))

    real_write = os.write
    state = {"first": True}

    def failing_write(fd: int, data: bytes) -> int:
        if state["first"]:
            state["first"] = False
            raise OSError(errno.ENOSPC, "fake ENOSPC")
        return real_write(fd, data)

    # Act
    with mock.patch(
        "gps_denied_onboard.components.c13_fdr.writer.os.write", side_effect=failing_write
    ):
        client.enqueue(_payload(0))
        deadline = time.monotonic() + 2.0
        while time.monotonic() < deadline and not writer.is_degraded():
            time.sleep(0.01)

    # Assert
    assert writer.is_degraded()
    assert len(alerts) >= 1
    assert "FDR write failure" in alerts[0]
    writer.stop()


def test_ac6_stop_drains_and_releases_lock(
    flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> None:
    # Arrange
    _alerts, alert_fn = _collect_alerts()
    client = _make_client()
    writer = FileFdrWriter(
        flight_root=flight_root,
        flight_id=flight_id,
        config=base_config,
        fdr_clients=[client],
        gcs_alert=alert_fn,
    )
    writer.start()
    writer.open_flight(_make_header(flight_id))
    for i in range(50):
        client.enqueue(_payload(i))

    # Act
    writer.stop()

    # Assert — a second writer can claim the filelock.
    second = FileFdrWriter(
        flight_root=flight_root,
        flight_id=uuid4(),
        config=base_config,
        fdr_clients=[_make_client("c5_state")],
        gcs_alert=alert_fn,
    )
    second.start()  # would raise if lock still held
    second.stop()


def test_ac7_segment_layout(flight_root: Path, flight_id: UUID) -> None:
    # Arrange
    config = FdrWriterConfig(segment_size_bytes=1024, batch_size=4, flight_cap_bytes=1024**3)
    _alerts, alert_fn = _collect_alerts()
    client = _make_client()
    writer = FileFdrWriter(
        flight_root=flight_root,
        flight_id=flight_id,
        config=config,
        fdr_clients=[client],
        gcs_alert=alert_fn,
    )
    writer.start()
    writer.open_flight(_make_header(flight_id))
    for i in range(40):
        client.enqueue(_payload(i))
    deadline = time.monotonic() + 5.0
    while time.monotonic() < deadline and client._buffer_size() > 0:
        time.sleep(0.01)
    writer.close_flight()

    # Assert
    flight_dir = flight_root / str(flight_id)
    names = sorted(p.name for p in flight_dir.iterdir() if p.is_file())
    for name in names:
        assert name.startswith("segment-") and name.endswith(".fdr"), name
        # 4-digit zero-padded.
        stem = name[len("segment-") : -len(".fdr")]
        assert len(stem) == 4 and stem.isdigit()


def test_ac8_steady_state_no_overrun(
    flight_root: Path, flight_id: UUID, base_config: FdrWriterConfig
) -> None:
    # Arrange — a small burst that the writer drains within a few seconds.
    _alerts, alert_fn = _collect_alerts()
    client = _make_client(capacity=2048)
    writer = FileFdrWriter(
        flight_root=flight_root,
        flight_id=flight_id,
        config=base_config,
        fdr_clients=[client],
        gcs_alert=alert_fn,
    )
    overrun_seen = {"count": 0}

    def overrun_hook(record: FdrRecord) -> None:
        overrun_seen["count"] += 1

    client.on_overrun = overrun_hook
    writer.start()
    writer.open_flight(_make_header(flight_id))

    # Act — emit 200 records spaced ~5 ms apart (~200 Hz steady state).
    for i in range(200):
        client.enqueue(_payload(i))
        time.sleep(0.001)

    deadline = time.monotonic() + 5.0
    while time.monotonic() < deadline and client._buffer_size() > 0:
        time.sleep(0.01)

    # Assert
    assert overrun_seen["count"] == 0
    writer.close_flight()


def test_double_start_raises(writer: FileFdrWriter, flight_id: UUID) -> None:
    from gps_denied_onboard.components.c13_fdr import FdrWriterError

    # Arrange
    writer.start()
    # Assert
    with pytest.raises(FdrWriterError):
        writer.start()
    writer.open_flight(_make_header(flight_id))
