"""Multi-subscriber telemetry fan-out (AZ-391 / E-C8).

The single decode thread per adapter pushes :class:`FcTelemetryFrame`
into a :class:`SubscriptionBus`; the bus fans it out synchronously to
every registered callback. Cancelling a subscription removes the
callback from the next dispatch tick.

Why synchronous fan-out and not per-subscriber threads:

- ``FcAdapter`` callers (C1 VIO, C5 StateEstimator) own their own
  decode-side ring buffers; the bus's responsibility is dispatch only.
- The contract says callbacks fire ON the decoder thread (Invariant
  8). Adding queue/thread per subscriber would (a) increase latency,
  (b) hide cross-callback ordering bugs, (c) require shutdown-order
  guarantees we don't actually need.

Slow subscribers MUST not block the decode loop beyond 100 µs per
the AZ-391 constraint. The bus does NOT enforce this — subscribers
are trusted to use a non-blocking enqueue + drain on their own
thread. (See `_inbound_mavlink._fire_callbacks` for the catch +
DEBUG log path that prevents a subscriber crash from killing the
decode loop.)
"""

from __future__ import annotations

import itertools
import threading
from typing import Final

from gps_denied_onboard._types.fc import FcTelemetryFrame, TelemetryCallback
from gps_denied_onboard.logging import get_logger

__all__ = ["SubscriptionBus", "SubscriptionHandle"]


_CALLBACK_ERROR_KIND: Final[str] = "c8.inbound.callback_error"


class SubscriptionHandle:
    """Concrete `Subscription` Protocol implementation."""

    def __init__(self, bus: SubscriptionBus, sub_id: int) -> None:
        self._bus = bus
        self._sub_id = sub_id

    def cancel(self) -> None:
        self._bus._cancel(self._sub_id)


class SubscriptionBus:
    """Lock-guarded subscriber registry + fan-out dispatch."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._subscribers: dict[int, TelemetryCallback] = {}
        self._next_id = itertools.count(1)
        self._log = get_logger("c8_fc_adapter.subscription_bus")

    def subscribe(self, callback: TelemetryCallback) -> SubscriptionHandle:
        with self._lock:
            sub_id = next(self._next_id)
            self._subscribers[sub_id] = callback
        return SubscriptionHandle(self, sub_id)

    def _cancel(self, sub_id: int) -> None:
        with self._lock:
            self._subscribers.pop(sub_id, None)

    @property
    def subscriber_count(self) -> int:
        with self._lock:
            return len(self._subscribers)

    def dispatch(self, frame: FcTelemetryFrame) -> None:
        """Fan ``frame`` out to every currently-registered subscriber.

        Subscriber exceptions are caught + DEBUG-logged so one bad
        subscriber cannot kill the decode loop (Invariant 8). The
        callback list is snapshot under the lock; callbacks fire OUTSIDE
        the lock so a callback that calls back into ``subscribe`` /
        ``cancel`` does not deadlock.
        """
        with self._lock:
            callbacks = list(self._subscribers.items())
        for sub_id, cb in callbacks:
            try:
                cb(frame)
            except Exception as exc:
                self._log.debug(
                    f"c8.inbound.callback_error: sub_id={sub_id} {exc!r}",
                    extra={
                        "kind": _CALLBACK_ERROR_KIND,
                        "kv": {"sub_id": sub_id, "error": repr(exc)},
                    },
                )
