"""AZ-390 — FcAdapter + GcsAdapter Protocols + DTOs + factories + composition.

Covers all 10 ACs:
1. Protocol conformance via @runtime_checkable
2. DTOs frozen + slots
3. Enum membership
4. Factory rejects build-flag OFF
5. Factory rejects unknown strategy at config-load
6. Single-writer outbound thread
7. GcsAdapter factory parallel coverage
8. Public API re-exports
9. Error hierarchy catchability
10. INFO log on build
"""

from __future__ import annotations

import dataclasses
import logging
import threading
from collections.abc import Callable

import pytest

from gps_denied_onboard._types.emitted import EmittedExternalPosition
from gps_denied_onboard._types.fc import (
    AttitudeSample,
    FcKind,
    FcTelemetryFrame,
    FlightState,
    FlightStateSignal,
    GpsHealth,
    GpsStatus,
    ImuTelemetrySample,
    OperatorCommand,
    PortConfig,
    Severity,
    Subscription,
    TelemetryKind,
)
from gps_denied_onboard._types.state import EstimatorOutput
from gps_denied_onboard.components.c8_fc_adapter import (
    FcAdapter,
    GcsAdapter,
    ReplaySink,
)
from gps_denied_onboard.components.c8_fc_adapter.errors import (
    FcAdapterConfigError,
    FcAdapterError,
    FcEmitError,
    FcOpenError,
    GcsAdapterConfigError,
    GcsAdapterError,
    GcsEmitError,
    SigningHandshakeError,
    SourceSetSwitchError,
    SourceSetSwitchNotSupportedError,
)
from gps_denied_onboard.config import Config, ConfigError, FcConfig, GcsConfig
from gps_denied_onboard.runtime_root.fc_factory import (
    OutboundThreadAlreadyBoundError,
    bind_outbound_emit_thread,
    build_fc_adapter,
    build_gcs_adapter,
    clear_outbound_thread_binding,
    clear_strategy_registries,
    register_fc_adapter,
    register_gcs_adapter,
)


@pytest.fixture(autouse=True)
def _isolate_factory_state() -> None:
    # Arrange — every test starts from a clean registry + thread binding.
    clear_strategy_registries()
    clear_outbound_thread_binding()
    yield
    clear_strategy_registries()
    clear_outbound_thread_binding()


# ----------------------------------------------------------------------
# AC-1: Protocol conformance


class _FcStub:
    def open(self, port: PortConfig, signing_key: bytes | None) -> None: ...
    def close(self) -> None: ...
    def subscribe_telemetry(self, callback: Callable[[FcTelemetryFrame], None]) -> Subscription:
        class _Sub:
            def cancel(self) -> None: ...

        return _Sub()

    def emit_external_position(self, output: EstimatorOutput) -> EmittedExternalPosition:
        return EmittedExternalPosition(
            fc_kind=FcKind.ARDUPILOT_PLANE,
            horiz_accuracy_m=1.0,
            source_label=output.source_label,
            emitted_at=0,
            sequence_number=0,
        )

    def emit_status_text(self, msg: str, severity: Severity) -> None: ...
    def request_source_set_switch(self) -> None: ...
    def current_flight_state(self) -> FlightStateSignal:
        return FlightStateSignal(
            state=FlightState.INIT,
            last_valid_gps_hint_wgs84=None,
            last_valid_gps_age_ms=None,
            captured_at=0,
        )


class _GcsStub:
    def open(self, port: PortConfig) -> None: ...
    def close(self) -> None: ...
    def emit_summary(self, output: EstimatorOutput) -> None: ...
    def subscribe_operator_commands(
        self, callback: Callable[[OperatorCommand], None]
    ) -> Subscription:
        class _Sub:
            def cancel(self) -> None: ...

        return _Sub()

    def emit_status_text(self, msg: str, severity: Severity) -> None: ...


def test_ac1_fc_protocol_conformance() -> None:
    # Assert
    assert isinstance(_FcStub(), FcAdapter)


def test_ac1_gcs_protocol_conformance() -> None:
    # Assert
    assert isinstance(_GcsStub(), GcsAdapter)


def test_ac1_replay_sink_protocol_conformance() -> None:
    # Arrange
    class _Sink:
        def write(self, output: EstimatorOutput) -> None: ...

    # Assert
    assert isinstance(_Sink(), ReplaySink)


def test_ac1_protocol_rejects_missing_method() -> None:
    # Arrange
    class _Incomplete:
        def open(self, port: PortConfig, signing_key: bytes | None) -> None: ...
        def close(self) -> None: ...

        # missing the other methods

    # Assert
    assert not isinstance(_Incomplete(), FcAdapter)


# ----------------------------------------------------------------------
# AC-2: DTOs frozen + slots


@pytest.mark.parametrize(
    "dto, init_kwargs",
    [
        (PortConfig, {"device": "/dev/ttyTHS1", "baud": 921600, "fc_kind": FcKind.ARDUPILOT_PLANE}),
        (
            ImuTelemetrySample,
            {"ts_ns": 0, "accel_xyz": (0.0, 0.0, 0.0), "gyro_xyz": (0.0, 0.0, 0.0)},
        ),
        (AttitudeSample, {"ts_ns": 0, "roll_rad": 0.0, "pitch_rad": 0.0, "yaw_rad": 0.0}),
        (GpsHealth, {"status": GpsStatus.STABLE, "fix_age_ms": 0, "captured_at": 0}),
        (
            FlightStateSignal,
            {
                "state": FlightState.INIT,
                "last_valid_gps_hint_wgs84": None,
                "last_valid_gps_age_ms": None,
                "captured_at": 0,
            },
        ),
        (OperatorCommand, {"command": "test", "payload": {}, "received_at": 0}),
        (
            EmittedExternalPosition,
            {
                "fc_kind": FcKind.ARDUPILOT_PLANE,
                "horiz_accuracy_m": 1.0,
                "source_label": "visual_propagated",
                "emitted_at": 0,
                "sequence_number": 0,
            },
        ),
    ],
)
def test_ac2_dto_frozen_and_slotted(dto: type, init_kwargs: dict) -> None:
    # Arrange
    instance = dto(**init_kwargs)

    # Assert — frozen
    with pytest.raises(dataclasses.FrozenInstanceError):
        any_field = next(iter(init_kwargs))
        setattr(instance, any_field, init_kwargs[any_field])

    # Assert — slots
    assert hasattr(dto, "__slots__")
    assert len(dto.__slots__) > 0


def test_ac2_fc_telemetry_frame_dto_frozen() -> None:
    # Arrange
    frame = FcTelemetryFrame(
        kind=TelemetryKind.IMU_SAMPLE,
        payload=ImuTelemetrySample(ts_ns=0, accel_xyz=(0.0, 0.0, 0.0), gyro_xyz=(0.0, 0.0, 0.0)),
        received_at=0,
        signed=False,
    )

    # Assert
    with pytest.raises(dataclasses.FrozenInstanceError):
        frame.signed = True  # type: ignore[misc]
    assert hasattr(FcTelemetryFrame, "__slots__")


# ----------------------------------------------------------------------
# AC-3: Enum membership


def test_ac3_fc_kind_has_two_members() -> None:
    # Assert
    assert {m.name for m in FcKind} == {"ARDUPILOT_PLANE", "INAV", "GCS_QGC"}


def test_ac3_flight_state_has_five_members() -> None:
    # Assert
    assert {m.name for m in FlightState} == {"INIT", "ARMED", "IN_FLIGHT", "ON_GROUND", "FAILED"}


def test_ac3_gps_status_has_five_members() -> None:
    # Assert
    assert {m.name for m in GpsStatus} == {
        "NO_FIX",
        "DEGRADED",
        "STABLE",
        "STABLE_NON_SPOOFED",
        "SPOOFED",
    }


def test_ac3_severity_values_mirror_mavlink() -> None:
    # Assert
    assert Severity.INFO.value == 6
    assert Severity.WARNING.value == 4
    assert Severity.ERROR.value == 3


def test_ac3_telemetry_kind_has_four_members() -> None:
    # Assert
    assert {m.name for m in TelemetryKind} == {
        "IMU_SAMPLE",
        "ATTITUDE",
        "GPS_HEALTH",
        "MAV_STATE",
    }


# ----------------------------------------------------------------------
# AC-4: Factory rejects build-flag OFF


def test_ac4_fc_factory_rejects_build_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    monkeypatch.setenv("BUILD_FC_ARDUPILOT_PLANE", "OFF")
    register_fc_adapter("ardupilot_plane", lambda **_: _FcStub())
    config = Config(fc=FcConfig(adapter="ardupilot_plane"))

    # Act + Assert
    with pytest.raises(FcAdapterConfigError, match=r"BUILD_FC_ARDUPILOT_PLANE is OFF"):
        build_fc_adapter(config)


def test_ac4_fc_factory_passes_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    monkeypatch.setenv("BUILD_FC_ARDUPILOT_PLANE", "ON")
    register_fc_adapter("ardupilot_plane", lambda **_: _FcStub())
    config = Config(fc=FcConfig(adapter="ardupilot_plane"))

    # Act
    adapter = build_fc_adapter(config)

    # Assert
    assert isinstance(adapter, FcAdapter)


# ----------------------------------------------------------------------
# AC-5: Factory rejects unknown strategy at config-load


def test_ac5_unknown_fc_strategy_rejected_at_config_load() -> None:
    # Act + Assert — happens at construction time, not at build time
    with pytest.raises(ConfigError, match=r"not in \['ardupilot_plane', 'inav'\]"):
        FcConfig(adapter="garbage_fc")


def test_ac5_unknown_gcs_strategy_rejected_at_config_load() -> None:
    # Act + Assert
    with pytest.raises(ConfigError, match=r"not in \['qgc_mavlink'\]"):
        GcsConfig(adapter="garbage_gcs")


def test_ac5_inav_signing_key_combination_rejected() -> None:
    # Act + Assert — iNav with signing key is RESTRICT-COMM-2 violation
    with pytest.raises(ConfigError, match=r"RESTRICT-COMM-2"):
        FcConfig(adapter="inav", signing_key_source="ephemeral_per_flight")


def test_ac5_unregistered_strategy_rejected_at_build_with_clear_message() -> None:
    # Arrange — strategy is in the known set but the binary didn't register a factory
    config = Config(fc=FcConfig(adapter="inav", signing_key_source="none"))

    # Act + Assert
    with pytest.raises(FcAdapterConfigError, match=r"not registered"):
        build_fc_adapter(config)


# ----------------------------------------------------------------------
# AC-6: Single-writer outbound thread


def test_ac6_first_bind_returns_thread_ident() -> None:
    # Act
    bound = bind_outbound_emit_thread()

    # Assert
    assert bound == threading.get_ident()


def test_ac6_second_bind_from_different_thread_rejected() -> None:
    # Arrange
    bind_outbound_emit_thread(thread_ident=1)

    errors: list[BaseException] = []

    def attempt_rebind() -> None:
        try:
            bind_outbound_emit_thread(thread_ident=2)
        except OutboundThreadAlreadyBoundError as exc:
            errors.append(exc)

    # Act
    t = threading.Thread(target=attempt_rebind)
    t.start()
    t.join()

    # Assert
    assert len(errors) == 1
    assert isinstance(errors[0], RuntimeError)


def test_ac6_rebind_same_thread_idempotent() -> None:
    # Arrange
    first = bind_outbound_emit_thread(thread_ident=42)

    # Act
    second = bind_outbound_emit_thread(thread_ident=42)

    # Assert — re-binding the SAME thread is idempotent (composition root may run twice in tests)
    assert first == second == 42


# ----------------------------------------------------------------------
# AC-7: GcsAdapter factory parallel coverage


def test_ac7_gcs_factory_resolves_known_strategy(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    monkeypatch.setenv("BUILD_GCS_QGC_MAVLINK", "ON")
    register_gcs_adapter("qgc_mavlink", lambda **_: _GcsStub())
    config = Config(gcs=GcsConfig(adapter="qgc_mavlink"))

    # Act
    adapter = build_gcs_adapter(config)

    # Assert
    assert isinstance(adapter, GcsAdapter)


def test_ac7_gcs_factory_rejects_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    monkeypatch.setenv("BUILD_GCS_QGC_MAVLINK", "OFF")
    register_gcs_adapter("qgc_mavlink", lambda **_: _GcsStub())
    config = Config(gcs=GcsConfig(adapter="qgc_mavlink"))

    # Act + Assert
    with pytest.raises(GcsAdapterConfigError, match=r"BUILD_GCS_QGC_MAVLINK is OFF"):
        build_gcs_adapter(config)


# ----------------------------------------------------------------------
# AC-9: Error hierarchy catchability


def test_ac9_every_fc_error_is_fc_adapter_error() -> None:
    # Assert — all FC errors share the base
    for err_cls in [
        FcOpenError,
        FcEmitError,
        SigningHandshakeError,
        SourceSetSwitchError,
        SourceSetSwitchNotSupportedError,
        FcAdapterConfigError,
    ]:
        assert issubclass(err_cls, FcAdapterError), err_cls


def test_ac9_source_set_switch_not_supported_is_subclass_of_switch_error() -> None:
    # Assert
    assert issubclass(SourceSetSwitchNotSupportedError, SourceSetSwitchError)


def test_ac9_gcs_errors_share_base() -> None:
    # Assert
    for err_cls in [GcsEmitError, GcsAdapterConfigError]:
        assert issubclass(err_cls, GcsAdapterError), err_cls


def test_ac9_fc_and_gcs_trees_are_disjoint() -> None:
    # Assert — catching FcAdapterError must NOT catch GcsAdapterError
    assert not issubclass(GcsAdapterError, FcAdapterError)
    assert not issubclass(FcAdapterError, GcsAdapterError)


# ----------------------------------------------------------------------
# AC-10: INFO log on build


def test_ac10_info_log_on_fc_build(
    monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
    # Arrange
    monkeypatch.setenv("BUILD_FC_ARDUPILOT_PLANE", "ON")
    register_fc_adapter("ardupilot_plane", lambda **_: _FcStub())
    config = Config(fc=FcConfig(adapter="ardupilot_plane", port_device="/dev/ttyS0"))

    # Act
    with caplog.at_level(logging.INFO, logger="runtime_root.fc_factory"):
        build_fc_adapter(config)

    # Assert — exactly one strategy_loaded record
    matches = [
        r for r in caplog.records if getattr(r, "kind", None) == "c8.adapter.strategy_loaded"
    ]
    assert len(matches) == 1
    assert matches[0].kv["strategy"] == "ardupilot_plane"
    assert matches[0].kv["port_device"] == "/dev/ttyS0"


def test_ac10_info_log_on_gcs_build(
    monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
    # Arrange
    monkeypatch.setenv("BUILD_GCS_QGC_MAVLINK", "ON")
    register_gcs_adapter("qgc_mavlink", lambda **_: _GcsStub())
    config = Config(gcs=GcsConfig(adapter="qgc_mavlink", port_device="/dev/ttyS1"))

    # Act
    with caplog.at_level(logging.INFO, logger="runtime_root.fc_factory"):
        build_gcs_adapter(config)

    # Assert
    matches = [r for r in caplog.records if getattr(r, "kind", None) == "c8.gcs.strategy_loaded"]
    assert len(matches) == 1


# ----------------------------------------------------------------------
# NFR: build perf (loose budget — sanity check, not microbench)


def test_nfr_perf_fc_build_under_50ms(monkeypatch: pytest.MonkeyPatch) -> None:
    # Arrange
    import time

    monkeypatch.setenv("BUILD_FC_ARDUPILOT_PLANE", "ON")
    register_fc_adapter("ardupilot_plane", lambda **_: _FcStub())
    config = Config(fc=FcConfig(adapter="ardupilot_plane"))

    # Act
    start = time.monotonic()
    build_fc_adapter(config)
    elapsed_s = time.monotonic() - start

    # Assert
    assert elapsed_s < 0.05, f"build took {elapsed_s * 1000:.2f}ms (budget 50ms)"


# ----------------------------------------------------------------------
# Coverage of the FcConfig.signing_key_source validator


def test_signing_key_source_unknown_value_rejected() -> None:
    # Act + Assert
    with pytest.raises(ConfigError, match=r"signing_key_source"):
        FcConfig(adapter="ardupilot_plane", signing_key_source="garbage")


def test_gcs_summary_rate_out_of_range_rejected() -> None:
    # AZ-397 widened the valid range to [0.5, 5.0] (AC-10); the boundary
    # cases below now fall OUTSIDE the new range.
    # Act + Assert — too high
    with pytest.raises(ConfigError, match=r"summary_rate_hz"):
        GcsConfig(summary_rate_hz=5.1)
    # Too low
    with pytest.raises(ConfigError, match=r"summary_rate_hz"):
        GcsConfig(summary_rate_hz=0.4)
