"""AZ-396 — D-C8-2 source-set switch AC tests."""

from __future__ import annotations

import logging
import threading
import time
from collections.abc import Iterable
from typing import Any
from unittest import mock

import pytest

from gps_denied_onboard._types.fc import FcKind, PortConfig, Severity
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
    CovarianceProjector,
)
from gps_denied_onboard.components.c8_fc_adapter.errors import SourceSetSwitchError
from gps_denied_onboard.components.c8_fc_adapter.pymavlink_ardupilot_adapter import (
    PymavlinkArdupilotAdapter,
)
from gps_denied_onboard.config import load_config
from gps_denied_onboard.runtime_root.spoof_recovery_sink import SpoofRecoverySink

# AC-1 / AC-2 / AC-3: pymavlink ardupilotmega command id for SET_EKF_SOURCE_SET.
_CMD_SET_EKF_SOURCE_SET = 42007
_MAV_RESULT_ACCEPTED = 0
_MAV_RESULT_FAILED = 4


class _AckMsg:
    def __init__(self, command: int, result: int) -> None:
        self.command = command
        self.result = result


class _MavStub:
    def __init__(self) -> None:
        self.command_long_calls: list[tuple[int, ...]] = []
        self.statustext_calls: list[tuple[int, bytes]] = []
        self.named_value_float_calls: list[tuple[Any, ...]] = []
        self.gps_input_calls: list[tuple[Any, ...]] = []

    def command_long_send(
        self,
        target_system: int,
        target_component: int,
        command: int,
        confirmation: int,
        p1: float,
        p2: float,
        p3: float,
        p4: float,
        p5: float,
        p6: float,
        p7: float,
    ) -> None:
        self.command_long_calls.append((target_system, target_component, command, confirmation, p1))

    def statustext_send(self, severity: int, text: bytes) -> None:
        self.statustext_calls.append((severity, text))

    def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
        self.named_value_float_calls.append((time_boot_ms, name, value))

    def gps_input_send(self, *args: Any) -> None:
        self.gps_input_calls.append(args)


class _ConnStub:
    def __init__(self, ack_queue: Iterable[_AckMsg] | None = None) -> None:
        self.mav = _MavStub()
        self.target_system = 1
        self.target_component = 1
        self._ack_queue = list(ack_queue or [])
        self.closed = False

    def recv_match(self, *, type: str, blocking: bool, timeout: float | None) -> Any:
        # Real pymavlink filters by ``type``; the inbound decoder thread
        # calls recv_match without a type filter (None), the outbound
        # source-set switch calls with type="COMMAND_ACK". Honour that
        # so the inbound loop does not eat our ACK.
        if type != "COMMAND_ACK":
            return None
        if not self._ack_queue:
            return None
        return self._ack_queue.pop(0)

    def setup_signing(self, key: bytes) -> None:
        pass

    def close(self) -> None:
        self.closed = True


def _ap_config(
    *,
    source_set: int = 1,
    timeout_ms: int = 1500,
) -> Any:
    env = {
        "FC_ADAPTER": "ardupilot_plane",
        "FC_PORT_DEVICE": "/dev/null",
        "FC_PORT_BAUD": "921600",
        "FC_SIGNING_KEY_SOURCE": "none",
        "FC_SPOOF_RECOVERY_SOURCE_SET": str(source_set),
        "FC_SOURCE_SET_SWITCH_TIMEOUT_MS": str(timeout_ms),
    }
    return load_config(env=env, paths=(), require_env=False)


def _make_adapter(conn: _ConnStub, cfg: Any) -> PymavlinkArdupilotAdapter:
    fdr = mock.MagicMock()
    a = PymavlinkArdupilotAdapter(
        config=cfg,
        wgs_converter=mock.MagicMock(),
        covariance_projector=CovarianceProjector(fdr_client=fdr),
        fdr_client=fdr,
        flight_id="flt-az396",
        connect_factory=lambda device, baud: conn,
    )
    port = PortConfig(fc_kind=FcKind.ARDUPILOT_PLANE, device="/dev/null", baud=921600)
    a.open(port, signing_key=None)
    return a


# ----------------------------------------------------------------------
# AC-1: ACK success path


def test_ac1_ack_success(caplog: pytest.LogCaptureFixture) -> None:
    conn = _ConnStub(ack_queue=[_AckMsg(_CMD_SET_EKF_SOURCE_SET, _MAV_RESULT_ACCEPTED)])
    a = _make_adapter(conn, _ap_config(source_set=1))
    try:
        with caplog.at_level(logging.INFO):
            a.request_source_set_switch()
        assert len(conn.mav.command_long_calls) == 1
        _, _, cmd, _, p1 = conn.mav.command_long_calls[0]
        assert cmd == _CMD_SET_EKF_SOURCE_SET
        assert p1 == 1.0
        assert any(
            getattr(r, "kind", None) == "c8.ap.source_set_switch_executed" for r in caplog.records
        )
        assert any(sev == int(Severity.INFO.value) for sev, _ in conn.mav.statustext_calls)
        assert any(
            call.args[0].payload.get("kind") == "c8.ap.source_set_switch_executed"
            for call in a._fdr_client.enqueue.mock_calls  # type: ignore[attr-defined]
        )
    finally:
        a.close()


# ----------------------------------------------------------------------
# AC-2: non-success ACK raises


def test_ac2_non_success_ack_raises(caplog: pytest.LogCaptureFixture) -> None:
    conn = _ConnStub(ack_queue=[_AckMsg(_CMD_SET_EKF_SOURCE_SET, _MAV_RESULT_FAILED)])
    a = _make_adapter(conn, _ap_config())
    try:
        with caplog.at_level(logging.ERROR), pytest.raises(SourceSetSwitchError, match="result=4"):
            a.request_source_set_switch()
        assert any(
            getattr(r, "kind", None) == "c8.ap.source_set_switch_failed" for r in caplog.records
        )
        assert any(sev == int(Severity.ERROR.value) for sev, _ in conn.mav.statustext_calls)
    finally:
        a.close()


# ----------------------------------------------------------------------
# AC-3: ACK timeout raises


def test_ac3_ack_timeout_raises(caplog: pytest.LogCaptureFixture) -> None:
    conn = _ConnStub(ack_queue=[])  # never ACK
    a = _make_adapter(conn, _ap_config(timeout_ms=200))
    try:
        with (
            caplog.at_level(logging.ERROR),
            pytest.raises(SourceSetSwitchError, match="timeout after 200ms"),
        ):
            a.request_source_set_switch()
        assert any(
            getattr(r, "kind", None) == "c8.ap.source_set_switch_failed" for r in caplog.records
        )
    finally:
        a.close()


# ----------------------------------------------------------------------
# AC-4: configurable timeout (AC-3 already exercises a non-default value)


def test_ac4_configurable_timeout_uses_config_value() -> None:
    conn = _ConnStub(ack_queue=[])
    cfg = _ap_config(timeout_ms=500)
    a = _make_adapter(conn, cfg)
    try:
        with pytest.raises(SourceSetSwitchError, match="timeout after 500ms"):
            a.request_source_set_switch()
    finally:
        a.close()


# ----------------------------------------------------------------------
# AC-5: idempotence within 1 s — second call no-op'd


def test_ac5_idempotence_within_1s_rate_limited(caplog: pytest.LogCaptureFixture) -> None:
    conn = _ConnStub(ack_queue=[_AckMsg(_CMD_SET_EKF_SOURCE_SET, _MAV_RESULT_FAILED)])
    a = _make_adapter(conn, _ap_config())
    try:
        with pytest.raises(SourceSetSwitchError):
            a.request_source_set_switch()
        with caplog.at_level(logging.INFO):
            a.request_source_set_switch()
        # only one command_long_send hit the wire
        assert len(conn.mav.command_long_calls) == 1
        assert any(
            getattr(r, "kind", None) == "c8.ap.source_set_switch_rate_limited"
            for r in caplog.records
        )
    finally:
        a.close()


# ----------------------------------------------------------------------
# AC-6: idempotence after successful switch — second call after >1 s logs
# but does NOT re-emit


def test_ac6_idempotence_after_success_no_reissue(caplog: pytest.LogCaptureFixture) -> None:
    conn = _ConnStub(ack_queue=[_AckMsg(_CMD_SET_EKF_SOURCE_SET, _MAV_RESULT_ACCEPTED)])
    a = _make_adapter(conn, _ap_config())
    try:
        a.request_source_set_switch()
        # advance the monotonic clock past the 1 s rate-limit by mutating
        # the stored timestamp (we control the adapter under test).
        a._last_switch_attempt_ns -= 2_000_000_000  # type: ignore[attr-defined]
        with caplog.at_level(logging.INFO):
            a.request_source_set_switch()
        assert len(conn.mav.command_long_calls) == 1  # never re-issued
        assert any(
            getattr(r, "kind", None) == "c8.ap.source_set_switch_already_active"
            for r in caplog.records
        )
    finally:
        a.close()


# ----------------------------------------------------------------------
# AC-7: SpoofRecoverySink invokes request_source_set_switch on adapter


def test_ac7_spoof_recovery_sink_triggers_switch() -> None:
    mock_adapter = mock.MagicMock()
    sink = SpoofRecoverySink(mock_adapter)
    sink.start()
    try:
        sink.publish()
        # Wait briefly for the dispatch thread to process.
        for _ in range(20):
            if mock_adapter.request_source_set_switch.call_count >= 1:
                break
            time.sleep(0.05)
        assert mock_adapter.request_source_set_switch.call_count == 1
    finally:
        sink.stop()


def test_ac7_spoof_recovery_sink_isolates_errors() -> None:
    mock_adapter = mock.MagicMock()
    mock_adapter.request_source_set_switch.side_effect = SourceSetSwitchError("ACK timeout")
    sink = SpoofRecoverySink(mock_adapter)
    sink.start()
    try:
        sink.publish()
        # Sink must not crash; we can re-publish.
        for _ in range(20):
            if mock_adapter.request_source_set_switch.call_count >= 1:
                break
            time.sleep(0.05)
        assert mock_adapter.request_source_set_switch.call_count == 1
        sink.publish()
        for _ in range(20):
            if mock_adapter.request_source_set_switch.call_count >= 2:
                break
            time.sleep(0.05)
        assert mock_adapter.request_source_set_switch.call_count == 2
    finally:
        sink.stop()


# ----------------------------------------------------------------------
# AC-8: source_set_id from config


def test_ac8_source_set_id_from_config() -> None:
    conn = _ConnStub(ack_queue=[_AckMsg(_CMD_SET_EKF_SOURCE_SET, _MAV_RESULT_ACCEPTED)])
    a = _make_adapter(conn, _ap_config(source_set=2))
    try:
        a.request_source_set_switch()
        _, _, _, _, p1 = conn.mav.command_long_calls[0]
        assert p1 == 2.0
    finally:
        a.close()


# ----------------------------------------------------------------------
# AC-9: AZ-393 NotImplementedError placeholder replaced


def test_ac9_no_longer_raises_not_implemented() -> None:
    conn = _ConnStub(ack_queue=[_AckMsg(_CMD_SET_EKF_SOURCE_SET, _MAV_RESULT_ACCEPTED)])
    a = _make_adapter(conn, _ap_config())
    try:
        a.request_source_set_switch()  # would have raised NotImplementedError pre-AZ-396
    finally:
        a.close()


# ----------------------------------------------------------------------
# AC-10: STATUSTEXT severity matrix


def test_ac10_statustext_severity_matrix(caplog: pytest.LogCaptureFixture) -> None:
    # success → INFO
    conn1 = _ConnStub(ack_queue=[_AckMsg(_CMD_SET_EKF_SOURCE_SET, _MAV_RESULT_ACCEPTED)])
    a1 = _make_adapter(conn1, _ap_config())
    try:
        a1.request_source_set_switch()
        success_severities = [sev for sev, _ in conn1.mav.statustext_calls]
        assert int(Severity.INFO.value) in success_severities
    finally:
        a1.close()
    # failure → ERROR
    conn2 = _ConnStub(ack_queue=[_AckMsg(_CMD_SET_EKF_SOURCE_SET, _MAV_RESULT_FAILED)])
    a2 = _make_adapter(conn2, _ap_config())
    try:
        with pytest.raises(SourceSetSwitchError):
            a2.request_source_set_switch()
        failure_severities = [sev for sev, _ in conn2.mav.statustext_calls]
        assert int(Severity.ERROR.value) in failure_severities
    finally:
        a2.close()


# ----------------------------------------------------------------------
# Defence-in-depth: single-writer enforcement still applies


def test_single_writer_thread_enforced_on_switch() -> None:
    conn = _ConnStub(ack_queue=[_AckMsg(_CMD_SET_EKF_SOURCE_SET, _MAV_RESULT_ACCEPTED)])
    a = _make_adapter(conn, _ap_config())
    a.request_source_set_switch()  # bind main thread
    err: list[BaseException] = []

    def run() -> None:
        try:
            a.request_source_set_switch()
        except RuntimeError as e:
            err.append(e)

    t = threading.Thread(target=run)
    t.start()
    t.join(timeout=2.0)
    assert len(err) == 1
    assert "single-writer" in str(err[0]).lower()
    a.close()
