"""Spoof-recovery signal sink (AZ-396 / E-C8 runtime-root wiring).

C5's spoof-promotion gate (AZ-385) publishes a ``spoof_promotion_recovered``
signal when a previously-spoofed FC GPS source clears the recovery
window. The runtime root forwards that signal to the C8 AP adapter's
:meth:`request_source_set_switch` on the OUTBOUND emit thread so the
single-writer invariant (Invariant 8) is preserved.

This module owns ONLY the wiring side; the publisher side lives in
C5 (AZ-385 — not yet landed). The sink exposes a Protocol the
runtime root binds at composition-root build time:

- :class:`SpoofRecoveryPublisher` — Protocol the C5 gate implements.
- :class:`SpoofRecoverySink` — registers itself with a publisher and
  fires ``request_source_set_switch`` on the C8 adapter.

Single-writer enforcement: the sink ALWAYS dispatches the C8 adapter
call on a single bound thread (the C8 outbound thread). It uses a
thread-safe queue so the C5 thread can publish without blocking on
the C8 wire.
"""

from __future__ import annotations

import queue
import threading
from typing import Protocol

from gps_denied_onboard.components.c8_fc_adapter.errors import (
    FcAdapterError,
    SourceSetSwitchError,
)
from gps_denied_onboard.components.c8_fc_adapter.interface import FcAdapter
from gps_denied_onboard.logging import get_logger

__all__ = [
    "SpoofRecoveryPublisher",
    "SpoofRecoverySink",
]


class SpoofRecoveryPublisher(Protocol):
    """Protocol C5 (AZ-385) implements to surface the recovery signal."""

    def subscribe_spoof_promotion_recovered(self, callback: _Callback) -> object:
        """Register ``callback`` to be invoked when the gate recovers.

        Returns a handle whose ``cancel()`` method removes the subscription.
        """


class _Callback(Protocol):
    def __call__(self) -> None: ...


class SpoofRecoverySink:
    """Dispatches C5 spoof-recovery signals to the AP adapter outbound thread.

    Usage from the composition root::

        sink = SpoofRecoverySink(fc_adapter)
        sink.start()
        publisher.subscribe_spoof_promotion_recovered(sink.publish)
        # ... at shutdown ...
        sink.stop()
    """

    def __init__(self, fc_adapter: FcAdapter) -> None:
        self._fc = fc_adapter
        self._queue: queue.Queue[None] = queue.Queue(maxsize=16)
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        self._log = get_logger("runtime_root.spoof_recovery_sink")

    def start(self) -> None:
        """Start the dispatch thread; idempotent."""
        if self._thread is not None and self._thread.is_alive():
            return
        self._stop.clear()
        self._thread = threading.Thread(
            target=self._run, name="c8.spoof_recovery_sink", daemon=True
        )
        self._thread.start()

    def stop(self, *, join_timeout_s: float = 1.0) -> None:
        """Signal the dispatch thread to exit and join."""
        self._stop.set()
        # Wake the queue.get(...) blocker without leaking a real signal.
        try:
            self._queue.put_nowait(None)
        except queue.Full:
            pass
        if self._thread is not None:
            self._thread.join(timeout=join_timeout_s)
            self._thread = None

    def publish(self) -> None:
        """Invoked by the C5 publisher thread; enqueues a switch request."""
        try:
            self._queue.put_nowait(None)
        except queue.Full:
            # Bounded queue: 16 pending switches is far more than the
            # recovery gate can produce; if we ever hit this, something
            # upstream is flooding. Emit a WARN and drop — the C8
            # idempotence gate (Invariant 11) would suppress them anyway.
            self._log.warning(
                "c8.spoof_recovery_sink_queue_full",
                extra={"kind": "c8.spoof_recovery_sink_queue_full", "kv": {}},
            )

    def _run(self) -> None:
        while not self._stop.is_set():
            try:
                _ = self._queue.get(timeout=0.5)
            except queue.Empty:
                continue
            if self._stop.is_set():
                return
            try:
                self._fc.request_source_set_switch()
            except SourceSetSwitchError as exc:
                # Per spec § 5 error-handling: ERROR log + STATUSTEXT
                # already happened inside the adapter; we re-log at
                # DEBUG here so the sink-level audit shows the catch.
                self._log.debug(
                    f"c8.spoof_recovery_sink_switch_failed: {exc!r}",
                    extra={
                        "kind": "c8.spoof_recovery_sink_switch_failed",
                        "kv": {"error": repr(exc)},
                    },
                )
            except FcAdapterError as exc:
                self._log.warning(
                    f"c8.spoof_recovery_sink_adapter_error: {exc!r}",
                    extra={
                        "kind": "c8.spoof_recovery_sink_adapter_error",
                        "kv": {"error": repr(exc)},
                    },
                )
