"""AZ-273 — FdrClient lock-free SPSC ring buffer + public API.

Verifies the contract-relevant ACs (1, 3, 4, 5, 6, 7) of
``fdr_client_protocol`` v1.0.0. AC-2 (zero-alloc steady-state) and the
NFR-perf budgets (p99 ≤ 5 µs / ≤ 10 µs on Tier-2) are deferred to a
follow-up perf-instrumentation task; the pure-Python implementation
correctness is in scope here.
"""

from __future__ import annotations

import threading
import time
from collections.abc import Iterator

import pytest

from gps_denied_onboard.config import Config, FdrConfig
from gps_denied_onboard.fdr_client import (
    EnqueueResult,
    FdrClient,
    FdrRecord,
    FdrSpscViolationError,
    make_fdr_client,
)
from gps_denied_onboard.fdr_client.client import _reset_for_tests
from gps_denied_onboard.fdr_client.queue import SpscRingBuffer


@pytest.fixture(autouse=True)
def _reset_cache() -> Iterator[None]:
    _reset_for_tests()
    yield
    _reset_for_tests()


def _make_record(producer_id: str = "test.producer", frame_id: int | None = 0) -> FdrRecord:
    return FdrRecord(
        schema_version=1,
        ts="2026-05-11T00:00:00.000000Z",
        producer_id=producer_id,
        kind="log",
        payload={
            "level": "INFO",
            "component": producer_id,
            "frame_id": frame_id,
            "kind": "test.tick",
            "msg": "hello",
            "kv": {},
            "exc": None,
        },
    )


# ---------------------------------------------------------------------------
# AC-1: lock-free, never blocks — every enqueue returns in O(1), overrun on #1025.


def test_ac1_enqueue_never_blocks_and_returns_overrun_on_overflow() -> None:
    # Arrange
    client = FdrClient(producer_id="c1_vio", capacity=1024)

    # Act
    last_result = EnqueueResult.OK
    timings: list[float] = []
    for i in range(1025):
        start = time.perf_counter()
        last_result = client.enqueue(_make_record(frame_id=i))
        timings.append(time.perf_counter() - start)

    # Assert
    assert last_result == EnqueueResult.OVERRUN, "the 1025th enqueue must overrun"
    # Pure-Python budget: every individual call must return under 50 ms
    # (the NFR-perf 50 µs budget is Tier-2-only; we keep a generous
    # ceiling here to catch genuine blocking regressions only).
    assert max(timings) < 0.05, f"slow enqueue suggests blocking; max={max(timings) * 1e6:.1f}µs"


# ---------------------------------------------------------------------------
# AC-3: capacity is config-driven via config.fdr.per_producer_capacity.


def test_ac3_capacity_from_per_producer_config() -> None:
    # Arrange
    fdr_block = FdrConfig(per_producer_capacity={"c1_vio": 4096})
    config = Config(fdr=fdr_block)

    # Act
    client = make_fdr_client("c1_vio", config)

    # Assert
    assert client._capacity() == 4096


def test_ac3_capacity_falls_back_to_default_queue_size() -> None:
    # Arrange
    config = Config(fdr=FdrConfig(queue_size=2048))

    # Act
    client = make_fdr_client("c2_vpr", config)

    # Assert
    assert client._capacity() == 2048


def test_ac3_non_power_of_two_rounds_up() -> None:
    # Arrange
    config = Config(fdr=FdrConfig(queue_size=1000))

    # Act
    client = make_fdr_client("c3_matcher", config)

    # Assert
    assert client._capacity() == 1024  # 1000 → next power of two


# ---------------------------------------------------------------------------
# AC-4: SPSC dequeue contract enforced by opt-in guard.


def test_ac4_spsc_guard_detects_concurrent_consumer_pop() -> None:
    # Arrange
    buf = SpscRingBuffer(capacity=16, enforce_spsc=True)
    barrier = threading.Barrier(2)
    errors: list[FdrSpscViolationError] = []

    def consume() -> None:
        barrier.wait()
        for _ in range(64):
            try:
                buf.pop()
            except FdrSpscViolationError as exc:
                errors.append(exc)
                return

    t1 = threading.Thread(target=consume)
    t2 = threading.Thread(target=consume)

    # Act
    t1.start()
    t2.start()
    t1.join(timeout=5.0)
    t2.join(timeout=5.0)

    # Assert
    assert errors, "second consumer thread must trip the SPSC guard"
    assert errors[0].side == "consumer"


def test_ac4_spsc_guard_detects_concurrent_producer_push() -> None:
    # Arrange
    buf = SpscRingBuffer(capacity=16, enforce_spsc=True)
    barrier = threading.Barrier(2)
    errors: list[FdrSpscViolationError] = []

    def produce() -> None:
        barrier.wait()
        for _ in range(64):
            try:
                buf.push(object())
            except FdrSpscViolationError as exc:
                errors.append(exc)
                return

    t1 = threading.Thread(target=produce)
    t2 = threading.Thread(target=produce)

    # Act
    t1.start()
    t2.start()
    t1.join(timeout=5.0)
    t2.join(timeout=5.0)

    # Assert
    assert errors, "second producer thread must trip the SPSC guard"
    assert errors[0].side == "producer"


def test_ac4_default_is_no_guard() -> None:
    # Arrange
    buf = SpscRingBuffer(capacity=16)  # enforce_spsc defaults to False

    # Act — two threads push and pop concurrently; no exception expected.
    def stress() -> None:
        for i in range(32):
            buf.push(i)
            buf.pop()

    t1 = threading.Thread(target=stress)
    t2 = threading.Thread(target=stress)
    t1.start()
    t2.start()
    t1.join(timeout=5.0)
    t2.join(timeout=5.0)

    # Assert — no exception, no SPSC complaints; production wiring opts out.


# ---------------------------------------------------------------------------
# AC-5: on_overrun hook is wired exactly once per overrun.


def test_ac5_on_overrun_hook_fires_once_per_overrun() -> None:
    # Arrange
    client = FdrClient(producer_id="c4_pose", capacity=16)
    seen: list[FdrRecord] = []
    client.on_overrun = seen.append
    # Fill the buffer (capacity 16 holds 15 records before overrun).
    for i in range(15):
        client.enqueue(_make_record(frame_id=i))
    offending = _make_record(frame_id=999)

    # Act
    result = client.enqueue(offending)

    # Assert
    assert result == EnqueueResult.OVERRUN
    assert seen == [offending]


def test_ac5_invalid_hook_rejected() -> None:
    # Arrange
    client = FdrClient(producer_id="c4_pose", capacity=16)

    # Act / Assert
    with pytest.raises(TypeError):
        client.on_overrun = "not_callable"  # type: ignore[assignment]


# ---------------------------------------------------------------------------
# AC-6: flush() drains the buffer.


def test_ac6_flush_returns_only_when_empty() -> None:
    # Arrange
    client = FdrClient(producer_id="c5_state", capacity=16)
    for i in range(8):
        client.enqueue(_make_record(frame_id=i))

    drained: list[FdrRecord] = []

    def drain() -> None:
        while True:
            item = client.pop_one()
            if item is None and client._buffer_size() == 0:
                return
            if item is not None:
                drained.append(item)

    drainer = threading.Thread(target=drain)
    drainer.start()

    # Act
    client.flush()

    # Assert
    drainer.join(timeout=5.0)
    assert client._buffer_size() == 0
    assert len(drained) == 8


# ---------------------------------------------------------------------------
# AC-7: empty producer_id raises ValueError.


def test_ac7_empty_producer_id_raises_value_error() -> None:
    # Arrange / Act / Assert
    with pytest.raises(ValueError, match="producer_id"):
        FdrClient(producer_id="", capacity=16)


def test_ac7_make_fdr_client_rejects_empty_producer_id() -> None:
    # Arrange
    config = Config()

    # Act / Assert
    with pytest.raises(ValueError, match="producer_id"):
        make_fdr_client("", config)


# ---------------------------------------------------------------------------
# Invariant: one client per producer_id (NFR-reliability).


def test_invariant_make_fdr_client_caches_by_producer_id() -> None:
    # Arrange
    config = Config()

    # Act
    a = make_fdr_client("c8_fc_adapter", config)
    b = make_fdr_client("c8_fc_adapter", config)

    # Assert
    assert a is b


# ---------------------------------------------------------------------------
# Invariant: enqueue does not mutate record.producer_id.


def test_invariant_enqueue_preserves_producer_id() -> None:
    # Arrange
    client = FdrClient(producer_id="c5_state", capacity=16)
    record = _make_record(producer_id="c5_state", frame_id=42)

    # Act
    client.enqueue(record)
    popped = client.pop_one()

    # Assert
    assert popped is record
    assert popped.producer_id == "c5_state"


# ---------------------------------------------------------------------------
# Buffer-level invariants: capacity validation.


def test_capacity_must_be_at_least_minimum() -> None:
    # Arrange / Act / Assert
    with pytest.raises(ValueError, match=">= 16"):
        SpscRingBuffer(capacity=8)


def test_capacity_must_be_power_of_two() -> None:
    # Arrange / Act / Assert
    with pytest.raises(ValueError, match="power of two"):
        SpscRingBuffer(capacity=20)


def test_drain_returns_fifo_order() -> None:
    # Arrange
    client = FdrClient(producer_id="c7_inference", capacity=16)
    records = [_make_record(frame_id=i) for i in range(5)]
    for r in records:
        client.enqueue(r)

    # Act
    drained = client.drain(max_records=10)

    # Assert
    assert drained == records
