"""AZ-391 — C8 inbound subscription / decoder / rings / fan-out unit tests.

Covers all 10 ACs of AZ-391 + the named NFRs that can be verified
inside a unit test (the ≥ 200 Hz sustain budget belongs in C8-PT-01).
"""

from __future__ import annotations

import logging
import threading
import time
from types import SimpleNamespace
from typing import Any
from unittest import mock

import pytest

from gps_denied_onboard._types.fc import (
    AttitudeSample,
    FcTelemetryFrame,
    FlightState,
    FlightStateSignal,
    GpsHealth,
    GpsStatus,
    ImuTelemetrySample,
    TelemetryKind,
)
from gps_denied_onboard.components.c8_fc_adapter._inbound_mavlink import (
    PymavlinkInboundDecoder,
)
from gps_denied_onboard.components.c8_fc_adapter._inbound_msp2 import (
    Msp2InavInboundDecoder,
)
from gps_denied_onboard.components.c8_fc_adapter._subscription import (
    SubscriptionBus,
)
from gps_denied_onboard.components.c8_fc_adapter._telemetry_rings import (
    TelemetryRing,
)

# ----------------------------------------------------------------------
# Helpers


def _ap_msg(msg_type: str, **fields: Any) -> Any:
    """Build a pymavlink-style stub message with `get_type()` + fields."""
    ns = SimpleNamespace(**fields)
    ns.get_type = lambda: msg_type
    return ns


def _make_decoder(bus: SubscriptionBus | None = None) -> PymavlinkInboundDecoder:
    if bus is None:
        bus = SubscriptionBus()
    src = mock.MagicMock()
    return PymavlinkInboundDecoder(src, bus)


# ----------------------------------------------------------------------
# AC-1: AP RAW_IMU decode


def test_ac1_ap_raw_imu_decode() -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    decoder = _make_decoder(bus)
    msg = _ap_msg(
        "RAW_IMU",
        time_usec=12345,
        xacc=10,
        yacc=20,
        zacc=-981,
        xgyro=1,
        ygyro=2,
        zgyro=3,
    )

    # Act
    dispatched = decoder.feed_one_message(msg)

    # Assert
    assert dispatched is True
    assert len(received) == 1
    frame = received[0]
    assert frame.kind is TelemetryKind.IMU_SAMPLE
    assert isinstance(frame.payload, ImuTelemetrySample)
    assert frame.payload.accel_xyz == (10.0, 20.0, -981.0)
    assert frame.payload.gyro_xyz == (1.0, 2.0, 3.0)
    # received_at is monotonic_ns at decode boundary (positive, non-zero)
    assert frame.received_at > 0


# ----------------------------------------------------------------------
# AC-2: AP ATTITUDE decode


def test_ac2_ap_attitude_decode() -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    decoder = _make_decoder(bus)
    msg = _ap_msg(
        "ATTITUDE",
        time_boot_ms=5000,
        roll=0.1,
        pitch=-0.2,
        yaw=1.5,
        rollspeed=0.0,
        pitchspeed=0.0,
        yawspeed=0.0,
    )

    # Act
    decoder.feed_one_message(msg)

    # Assert
    assert len(received) == 1
    assert received[0].kind is TelemetryKind.ATTITUDE
    payload = received[0].payload
    assert isinstance(payload, AttitudeSample)
    assert payload.roll_rad == pytest.approx(0.1)
    assert payload.pitch_rad == pytest.approx(-0.2)
    assert payload.yaw_rad == pytest.approx(1.5)


# ----------------------------------------------------------------------
# AC-3: AP GPS_RAW_INT -> GpsHealth fix-type mapping


@pytest.mark.parametrize(
    "fix_type, expected_status",
    [
        (0, GpsStatus.NO_FIX),
        (1, GpsStatus.NO_FIX),
        (2, GpsStatus.DEGRADED),
        (3, GpsStatus.STABLE),
        (4, GpsStatus.STABLE),  # DGPS
        (5, GpsStatus.STABLE),  # RTK_FLOAT
        (6, GpsStatus.STABLE),  # RTK_FIXED
    ],
)
def test_ac3_ap_gps_fix_type_mapping(fix_type: int, expected_status: GpsStatus) -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    decoder = _make_decoder(bus)
    msg = _ap_msg(
        "GPS_RAW_INT",
        time_usec=0,
        fix_type=fix_type,
        lat=505000000,
        lon=300000000,
        alt=100000,
        eph=0,
        epv=0,
        vel=0,
        cog=0,
        satellites_visible=8,
        alt_ellipsoid=0,
        h_acc=0,
        v_acc=0,
        vel_acc=0,
        hdg_acc=0,
        yaw=0,
    )

    # Act
    decoder.feed_one_message(msg)

    # Assert
    assert isinstance(received[0].payload, GpsHealth)
    assert received[0].payload.status is expected_status


def test_ac3_spoofing_sentinel_promotes_to_spoofed() -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    decoder = _make_decoder(bus)
    statustext = _ap_msg("STATUSTEXT", severity=4, text="GPS spoofing detected!", id=0, chunk_seq=0)
    gps_msg = _ap_msg(
        "GPS_RAW_INT",
        time_usec=0,
        fix_type=3,
        lat=505000000,
        lon=300000000,
        alt=100000,
        eph=0,
        epv=0,
        vel=0,
        cog=0,
        satellites_visible=8,
        alt_ellipsoid=0,
        h_acc=0,
        v_acc=0,
        vel_acc=0,
        hdg_acc=0,
        yaw=0,
    )

    # Act
    decoder.feed_one_message(statustext)
    decoder.feed_one_message(gps_msg)

    # Assert — the GPS frame after the sentinel is promoted to SPOOFED
    gps_frames = [f for f in received if f.kind is TelemetryKind.GPS_HEALTH]
    assert len(gps_frames) == 1
    assert gps_frames[0].payload.status is GpsStatus.SPOOFED


# ----------------------------------------------------------------------
# AC-4: AP HEARTBEAT -> FlightState mapping


@pytest.mark.parametrize(
    "system_status, base_mode, expected_state",
    [
        (0, 0, FlightState.INIT),  # UNINIT
        (1, 0, FlightState.INIT),  # BOOT
        (2, 0, FlightState.INIT),  # CALIBRATING
        (3, 0x00, FlightState.ON_GROUND),  # STANDBY, disarmed
        (3, 0x80, FlightState.ARMED),  # STANDBY, armed
        (4, 0x80, FlightState.IN_FLIGHT),  # ACTIVE
        (5, 0, FlightState.FAILED),  # CRITICAL
        (6, 0, FlightState.FAILED),  # EMERGENCY
        (7, 0, FlightState.ON_GROUND),  # POWEROFF
        (8, 0, FlightState.FAILED),  # FLIGHT_TERMINATION
    ],
)
def test_ac4_heartbeat_to_flight_state(
    system_status: int, base_mode: int, expected_state: FlightState
) -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    decoder = _make_decoder(bus)
    msg = _ap_msg(
        "HEARTBEAT",
        type=0,
        autopilot=3,
        base_mode=base_mode,
        custom_mode=0,
        system_status=system_status,
        mavlink_version=2,
    )

    # Act
    decoder.feed_one_message(msg)

    # Assert
    assert received[0].kind is TelemetryKind.MAV_STATE
    payload = received[0].payload
    assert isinstance(payload, FlightStateSignal)
    assert payload.state is expected_state


# ----------------------------------------------------------------------
# AC-5: iNav MSP2 decode (same DTOs as AP)


class _FakeMspSource:
    def __init__(
        self,
        imu: dict | None = None,
        attitude: dict | None = None,
        gps: dict | None = None,
        state: dict | None = None,
    ) -> None:
        self._imu = imu
        self._attitude = attitude
        self._gps = gps
        self._state = state

    def read_imu(self) -> dict | None:
        return self._imu

    def read_attitude(self) -> dict | None:
        return self._attitude

    def read_gps(self) -> dict | None:
        return self._gps

    def read_flight_state(self) -> dict | None:
        return self._state


def test_ac5_inav_msp2_imu_decode() -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    source = _FakeMspSource(
        imu={"accelerometer": [11, 22, -33], "gyroscope": [1, 2, 3]},
    )
    decoder = Msp2InavInboundDecoder(source, bus)

    # Act
    decoder.feed_one_tick()

    # Assert
    imu_frames = [f for f in received if f.kind is TelemetryKind.IMU_SAMPLE]
    assert len(imu_frames) == 1
    payload = imu_frames[0].payload
    assert isinstance(payload, ImuTelemetrySample)
    assert payload.accel_xyz == (11.0, 22.0, -33.0)
    assert payload.gyro_xyz == (1.0, 2.0, 3.0)
    # iNav is unsigned per RESTRICT-COMM-2
    assert imu_frames[0].signed is False


def test_ac5_inav_msp2_attitude_decode() -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    source = _FakeMspSource(attitude={"angx": 180.0, "angy": 0.0, "heading": 0.0})
    decoder = Msp2InavInboundDecoder(source, bus)

    # Act
    decoder.feed_one_tick()

    # Assert
    att = [f for f in received if f.kind is TelemetryKind.ATTITUDE]
    assert len(att) == 1
    payload = att[0].payload
    assert isinstance(payload, AttitudeSample)
    # 180 deg -> pi rad
    assert payload.roll_rad == pytest.approx(3.141592653589793)


def test_ac5_inav_spoofed_status_unreachable() -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    source = _FakeMspSource(gps={"fix": 2, "lat": 505000000, "lon": 300000000, "alt": 100.0})
    decoder = Msp2InavInboundDecoder(source, bus)

    # Act
    decoder.feed_one_tick()

    # Assert — iNav with valid fix is STABLE; SPOOFED is unreachable
    gps = [f for f in received if f.kind is TelemetryKind.GPS_HEALTH]
    assert len(gps) == 1
    assert gps[0].payload.status is GpsStatus.STABLE


# ----------------------------------------------------------------------
# AC-6: Bounded ring drop-oldest


def test_ac6_ring_drop_oldest_and_logs_first_overflow(
    caplog: pytest.LogCaptureFixture,
) -> None:
    # Arrange
    ring: TelemetryRing[int] = TelemetryRing(capacity=100, kind_name="imu")
    caplog.set_level(logging.INFO, logger="c8_fc_adapter.telemetry_ring")

    # Act
    for i in range(1000):
        ring.push(i)

    # Assert — ring contains the latest 100
    items = ring.snapshot()
    assert len(items) == 100
    assert items == list(range(900, 1000))
    # Dropped 900 items (the first 900 entered before any drops; once full,
    # each subsequent push drops one).
    assert ring.dropped_count == 900
    # Exactly ONE overflow record (first overflow), not 900 of them.
    overflow_logs = [
        r for r in caplog.records if getattr(r, "kind", None) == "c8.inbound.ring_overflow"
    ]
    assert len(overflow_logs) == 1
    assert overflow_logs[0].kv["ring_kind"] == "imu"


def test_ac6_ring_capacity_zero_rejected() -> None:
    # Act + Assert
    with pytest.raises(ValueError, match=r"capacity must be > 0"):
        TelemetryRing(capacity=0, kind_name="imu")


# ----------------------------------------------------------------------
# AC-7: Multi-subscriber fan-out


def test_ac7_multi_subscriber_fan_out_and_cancel() -> None:
    # Arrange
    bus = SubscriptionBus()
    counters = [0, 0, 0]

    def make_cb(idx: int):
        def _cb(_f: FcTelemetryFrame) -> None:
            counters[idx] += 1

        return _cb

    handles = [bus.subscribe(make_cb(i)) for i in range(3)]
    frame = _make_frame(TelemetryKind.IMU_SAMPLE)

    # Act 1 — all 3 receive
    bus.dispatch(frame)

    # Assert 1
    assert counters == [1, 1, 1]

    # Act 2 — cancel one, dispatch again
    handles[1].cancel()
    bus.dispatch(frame)

    # Assert 2
    assert counters == [2, 1, 2]


def test_ac7_subscriber_exception_does_not_kill_dispatch(
    caplog: pytest.LogCaptureFixture,
) -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda _f: (_ for _ in ()).throw(RuntimeError("boom")))
    bus.subscribe(lambda f: received.append(f))
    caplog.set_level(logging.DEBUG, logger="c8_fc_adapter.subscription_bus")
    frame = _make_frame(TelemetryKind.IMU_SAMPLE)

    # Act
    bus.dispatch(frame)

    # Assert — second subscriber STILL fired
    assert len(received) == 1
    # And the bad subscriber's exception was DEBUG-logged
    err_logs = [
        r for r in caplog.records if getattr(r, "kind", None) == "c8.inbound.callback_error"
    ]
    assert len(err_logs) == 1


# ----------------------------------------------------------------------
# AC-8: Warm-start hint surfaces within 1 s of first GPS_RAW_INT


def test_ac8_warm_start_hint_present_after_first_gps() -> None:
    # Arrange
    bus = SubscriptionBus()
    decoder = _make_decoder(bus)
    # Before any GPS: hint is None
    assert decoder.warm_start_hint is None

    # Act
    msg = _ap_msg(
        "GPS_RAW_INT",
        time_usec=0,
        fix_type=3,
        lat=505000000,
        lon=300000000,
        alt=125000,  # 50.5 / 30.0 / 125 m
        eph=0,
        epv=0,
        vel=0,
        cog=0,
        satellites_visible=8,
        alt_ellipsoid=0,
        h_acc=0,
        v_acc=0,
        vel_acc=0,
        hdg_acc=0,
        yaw=0,
    )
    decoder.feed_one_message(msg)

    # Assert
    hint = decoder.warm_start_hint
    assert hint is not None
    assert hint.lat_deg == pytest.approx(50.5)
    assert hint.lon_deg == pytest.approx(30.0)
    assert hint.alt_m == pytest.approx(125.0)


def test_ac8_warm_start_hint_propagates_to_flight_state_signal() -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    decoder = _make_decoder(bus)
    gps = _ap_msg(
        "GPS_RAW_INT",
        time_usec=0,
        fix_type=3,
        lat=505000000,
        lon=300000000,
        alt=125000,
        eph=0,
        epv=0,
        vel=0,
        cog=0,
        satellites_visible=8,
        alt_ellipsoid=0,
        h_acc=0,
        v_acc=0,
        vel_acc=0,
        hdg_acc=0,
        yaw=0,
    )
    heartbeat = _ap_msg(
        "HEARTBEAT",
        type=0,
        autopilot=3,
        base_mode=0,
        custom_mode=0,
        system_status=3,
        mavlink_version=2,
    )

    # Act
    decoder.feed_one_message(gps)
    decoder.feed_one_message(heartbeat)

    # Assert
    state_frames = [f for f in received if f.kind is TelemetryKind.MAV_STATE]
    assert len(state_frames) == 1
    payload = state_frames[0].payload
    assert payload.last_valid_gps_hint_wgs84 is not None
    assert payload.last_valid_gps_age_ms is not None


# ----------------------------------------------------------------------
# AC-9: Out-of-order drop + WARN


def test_ac9_out_of_order_dropped_with_warn(caplog: pytest.LogCaptureFixture) -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    decoder = _make_decoder(bus)
    caplog.set_level(logging.WARNING, logger="c8_fc_adapter.inbound_mavlink")
    # First message
    msg1 = _ap_msg(
        "ATTITUDE",
        time_boot_ms=1000,
        roll=0.0,
        pitch=0.0,
        yaw=0.0,
        rollspeed=0.0,
        pitchspeed=0.0,
        yawspeed=0.0,
    )
    decoder.feed_one_message(msg1)
    # Capture the first frame's received_at; we'll force the next frame to be
    # apparently older by injecting a manipulated `_last_ts_ns` entry.
    # Force the decoder's per-kind last-ts way into the future so any next
    # frame is "before" it.
    decoder._last_ts_ns[TelemetryKind.ATTITUDE] = time.monotonic_ns() + 10_000_000_000

    # Act
    msg2 = _ap_msg(
        "ATTITUDE",
        time_boot_ms=2000,
        roll=0.1,
        pitch=0.1,
        yaw=0.1,
        rollspeed=0.0,
        pitchspeed=0.0,
        yawspeed=0.0,
    )
    decoder.feed_one_message(msg2)

    # Assert
    att_frames = [f for f in received if f.kind is TelemetryKind.ATTITUDE]
    assert len(att_frames) == 1  # second frame dropped
    warn_logs = [
        r
        for r in caplog.records
        if getattr(r, "kind", None) == "c8.inbound.out_of_order_frame_dropped"
    ]
    assert len(warn_logs) == 1
    assert warn_logs[0].kv["telemetry_kind"] == "ATTITUDE"


# ----------------------------------------------------------------------
# AC-10: Decode-error isolation


def test_ac10_corrupt_frame_does_not_kill_decoder(
    caplog: pytest.LogCaptureFixture,
) -> None:
    # Arrange
    bus = SubscriptionBus()
    received: list[FcTelemetryFrame] = []
    bus.subscribe(lambda f: received.append(f))
    decoder = _make_decoder(bus)
    caplog.set_level(logging.DEBUG, logger="c8_fc_adapter.inbound_mavlink")
    # Corrupt RAW_IMU — `xacc` field missing (AttributeError) on access
    corrupt = SimpleNamespace()
    corrupt.get_type = lambda: "RAW_IMU"
    # Good RAW_IMU immediately after
    good = _ap_msg(
        "RAW_IMU",
        time_usec=0,
        xacc=1,
        yacc=2,
        zacc=3,
        xgyro=4,
        ygyro=5,
        zgyro=6,
    )

    # Act
    decoder.feed_one_message(corrupt)
    decoder.feed_one_message(good)

    # Assert — bad frame logged, good frame dispatched
    err_logs = [r for r in caplog.records if getattr(r, "kind", None) == "c8.inbound.decode_error"]
    assert len(err_logs) == 1
    imu_frames = [f for f in received if f.kind is TelemetryKind.IMU_SAMPLE]
    assert len(imu_frames) == 1
    assert imu_frames[0].payload.accel_xyz == (1.0, 2.0, 3.0)


# ----------------------------------------------------------------------
# NFR: IMU callback latency under 1 ms (loose budget for unit test)


def test_nfr_perf_imu_callback_under_1ms() -> None:
    # Arrange
    bus = SubscriptionBus()
    bus.subscribe(lambda _f: None)
    decoder = _make_decoder(bus)

    iters = 200
    msgs = [
        _ap_msg(
            "RAW_IMU",
            time_usec=i,
            xacc=i,
            yacc=i,
            zacc=i,
            xgyro=i,
            ygyro=i,
            zgyro=i,
        )
        for i in range(iters)
    ]

    # Act
    start = time.perf_counter()
    for m in msgs:
        decoder.feed_one_message(m)
    avg_s = (time.perf_counter() - start) / iters

    # Assert
    assert avg_s < 1e-3, f"avg {avg_s * 1e3:.3f}ms > 1ms budget"


# ----------------------------------------------------------------------
# NFR: Thread-safety smoke (single-producer / single-consumer over a ring)


def test_nfr_ring_thread_safety_smoke() -> None:
    # Arrange
    ring: TelemetryRing[int] = TelemetryRing(capacity=1024, kind_name="imu")
    n = 5000

    def producer() -> None:
        for i in range(n):
            ring.push(i)

    def consumer_snapshots() -> None:
        for _ in range(n):
            ring.snapshot()

    # Act
    threads = [
        threading.Thread(target=producer),
        threading.Thread(target=consumer_snapshots),
    ]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    # Assert — no crash; ring still has bounded size
    assert len(ring) <= 1024


# ----------------------------------------------------------------------
# Helpers (factories for test frames)


def _make_frame(kind: TelemetryKind) -> FcTelemetryFrame:
    if kind is TelemetryKind.IMU_SAMPLE:
        payload: Any = ImuTelemetrySample(
            ts_ns=0, accel_xyz=(0.0, 0.0, 0.0), gyro_xyz=(0.0, 0.0, 0.0)
        )
    elif kind is TelemetryKind.ATTITUDE:
        payload = AttitudeSample(ts_ns=0, roll_rad=0.0, pitch_rad=0.0, yaw_rad=0.0)
    elif kind is TelemetryKind.GPS_HEALTH:
        payload = GpsHealth(status=GpsStatus.STABLE, fix_age_ms=0, captured_at=0)
    else:
        payload = FlightStateSignal(
            state=FlightState.INIT,
            last_valid_gps_hint_wgs84=None,
            last_valid_gps_age_ms=None,
            captured_at=0,
        )
    return FcTelemetryFrame(kind=kind, payload=payload, received_at=0, signed=False)
