"""QGroundControl telemetry adapter (AZ-397 / E-C8).

The single concrete :class:`GcsAdapter` strategy in this cycle. Owns:

- **Outbound downsampled summary** — composition root invokes
  :meth:`emit_summary` at 5 Hz; the adapter divides by an internal
  counter to emit at the configured ``summary_rate_hz``
  (default 2 Hz; range [0.5, 5.0] per :class:`GcsConfig`).
- **STATUSTEXT mirror** — :meth:`emit_status_text` forwards a single
  MAVLink ``STATUSTEXT`` to the configured GCS UART.
- **Operator commands** — pymavlink's message handler is wired to
  translate inbound ``COMMAND_LONG`` / ``PARAM_REQUEST_*`` /
  ``REQUEST_DATA_STREAM`` / ``MISSION_REQUEST`` (and any other
  caller-registered types) into :class:`OperatorCommand` DTOs and
  hand them to a subscriber.

The wire uses the AP MAVLink semantic for ``horiz_accuracy``: meters.
iNav-targeted flights that route their summary here still emit
meters since QGC speaks MAVLink natively (per AZ-397 § Constraints).

Build flag: ``BUILD_GCS_QGC_MAVLINK``.
"""

from __future__ import annotations

import threading
from collections.abc import Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Final

from gps_denied_onboard._types.fc import (
    FcKind,
    OperatorCommand,
    OperatorCommandCallback,
    PortConfig,
    Severity,
    Subscription,
)
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.state import EstimatorOutput
from gps_denied_onboard.clock.wall_clock import WallClock
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
    CovarianceProjector,
)

if TYPE_CHECKING:
    from gps_denied_onboard.clock import Clock
from gps_denied_onboard.components.c8_fc_adapter._subscription import SubscriptionBus
from gps_denied_onboard.components.c8_fc_adapter.errors import (
    GcsAdapterConfigError,
    GcsEmitError,
)
from gps_denied_onboard.config import Config
from gps_denied_onboard.fdr_client.client import FdrClient
from gps_denied_onboard.fdr_client.records import FdrRecord
from gps_denied_onboard.logging import get_logger

__all__ = ["QgcTelemetryAdapter"]


# Composition-root invocation rate (Hz) for emit_summary; the adapter
# divides this by ``summary_rate_hz`` to compute the modulo divisor.
_COMPOSE_ROOT_INVOKE_HZ: Final[float] = 5.0
_DEFAULT_OPERATOR_COMMAND_TYPES: Final[tuple[str, ...]] = (
    "PARAM_REQUEST_LIST",
    "PARAM_REQUEST_READ",
    "PARAM_SET",
    "COMMAND_LONG",
    "COMMAND_INT",
    "REQUEST_DATA_STREAM",
    "MISSION_REQUEST",
    "MISSION_REQUEST_LIST",
    "MISSION_ITEM",
    "SET_MODE",
)


def _mav_severity(sev: Severity) -> int:
    return int(sev.value)


def _compute_downsample_modulo(summary_rate_hz: float) -> int:
    """Return the per-N-call emit modulo for ``summary_rate_hz`` against 5 Hz.

    Examples (the AC tests pin these): 5 Hz -> 1 (no downsample),
    2 Hz -> 2 (every 2nd call), 1 Hz -> 5 (every 5th), 0.5 Hz -> 10.
    Sub-1 Hz floors don't round to zero because we use ``round`` with
    a minimum of 1 below.
    """
    if summary_rate_hz <= 0.0:
        raise GcsAdapterConfigError(f"summary_rate_hz must be > 0; got {summary_rate_hz}")
    ratio = _COMPOSE_ROOT_INVOKE_HZ / summary_rate_hz
    return max(1, round(ratio))


class QgcTelemetryAdapter:
    """Concrete :class:`GcsAdapter` strategy.

    Threading:
    - Outbound (``emit_summary`` / ``emit_status_text``) is bound to a
      single composition-root thread; emitting from a second thread
      raises :class:`RuntimeError` (Invariant 8 mirror).
    - Inbound operator-command dispatch fires on the message-handler
      thread (pymavlink's recv loop). Subscriber callbacks are
      invoked synchronously; the bus isolates crashes per the C8
      inbound pattern.
    """

    def __init__(
        self,
        *,
        config: Config,
        wgs_converter: Any,
        covariance_projector: CovarianceProjector,
        fdr_client: FdrClient,
        clock: Clock | None = None,
        connect_factory: Callable[[str, int], Any] | None = None,
    ) -> None:
        self._config = config
        self._wgs_converter = wgs_converter
        self._cov_projector = covariance_projector
        self._fdr_client = fdr_client
        self._clock: Clock = clock if clock is not None else WallClock()
        self._connect_factory = connect_factory
        self._log = get_logger("c8_gcs_adapter.qgc")
        # The modulo divisor — computed once at construction so unit
        # tests can pin a 5 Hz call → N-frame relationship.
        self._downsample_modulo: int = _compute_downsample_modulo(float(config.gcs.summary_rate_hz))
        # Wire state ------------------------------------------------------
        self._connection: Any = None
        self._opened = False
        self._invocation_count = 0
        self._frame_seq = 0
        self._first_emit_logged = False
        self._open_emit_thread_ident: int | None = None
        self._operator_bus = SubscriptionBus()
        self._operator_handler_attached = False
        self._operator_lock = threading.Lock()

    # ------------------------------------------------------------------
    # GcsAdapter Protocol implementation

    def open(self, port: PortConfig) -> None:
        if self._opened:
            raise GcsAdapterConfigError("QgcTelemetryAdapter already opened")
        if port.fc_kind is not FcKind.GCS_QGC:
            raise GcsAdapterConfigError(
                f"QgcTelemetryAdapter requires FcKind.GCS_QGC; got {port.fc_kind!r}"
            )
        try:
            self._connection = self._connect(port)
        except Exception as exc:
            raise GcsAdapterConfigError(f"QGC MAVLink connect failed: {exc!r}") from exc
        self._opened = True
        self._invocation_count = 0
        self._frame_seq = 0
        self._first_emit_logged = False
        self._open_emit_thread_ident = None

    def close(self) -> None:
        if not self._opened:
            return
        try:
            if self._connection is not None and hasattr(self._connection, "close"):
                self._connection.close()
        finally:
            self._opened = False
            self._connection = None
            self._open_emit_thread_ident = None

    def emit_summary(self, output: EstimatorOutput) -> None:
        if not self._opened or self._connection is None:
            raise GcsEmitError("adapter not opened")
        self._enforce_single_writer()
        self._invocation_count += 1
        if (self._invocation_count % self._downsample_modulo) != 0:
            return  # downsampled — skip this call
        wgs = self._extract_wgs84(output)
        horiz_accuracy_m = self._cov_projector.to_ardupilot_horiz_accuracy_m(output)
        self._frame_seq += 1
        try:
            self._connection.mav.global_position_int_send(
                int(self._clock_ms_boot()),
                int(wgs.lat_deg * 1e7),
                int(wgs.lon_deg * 1e7),
                int(wgs.alt_m * 1000.0),  # WGS84 alt in mm
                int(wgs.alt_m * 1000.0),  # relative alt; placeholder
                0,
                0,
                0,  # vx, vy, vz (cm/s) — 0 for summary
                0,  # hdg (cdeg); 0 = unset
            )
            self._connection.mav.named_value_float_send(
                int(self._clock_ms_boot()),
                b"horiz_m",
                float(horiz_accuracy_m),
            )
        except Exception as exc:
            self._log.error(
                f"c8.gcs.summary_emit_failed: {exc!r}",
                extra={
                    "kind": "c8.gcs.summary_emit_failed",
                    "kv": {"error": repr(exc), "frame_seq": self._frame_seq},
                },
            )
            raise GcsEmitError(f"GCS summary emit failed: {exc!r}") from exc
        if not self._first_emit_logged:
            self._first_emit_logged = True
            self._log.info(
                "c8.gcs.first_summary_emit",
                extra={
                    "kind": "c8.gcs.first_summary_emit",
                    "kv": {
                        "frame_seq": self._frame_seq,
                        "downsample_modulo": self._downsample_modulo,
                    },
                },
            )
        self._log.debug(
            "c8.gcs.summary_emit",
            extra={
                "kind": "c8.gcs.summary_emit",
                "kv": {
                    "seq": self._frame_seq,
                    "lat": wgs.lat_deg,
                    "lon": wgs.lon_deg,
                    "horiz_accuracy_m": horiz_accuracy_m,
                    "source_label": output.source_label,
                },
            },
        )

    def subscribe_operator_commands(self, callback: OperatorCommandCallback) -> Subscription:
        handle = self._operator_bus.subscribe(callback)
        self._ensure_operator_handler_attached()
        return handle

    def emit_status_text(self, msg: str, severity: Severity) -> None:
        if not self._opened or self._connection is None:
            raise GcsEmitError("adapter not opened")
        self._enforce_single_writer()
        try:
            text = msg.encode("utf-8")[:50]
            self._connection.mav.statustext_send(_mav_severity(severity), text)
        except Exception as exc:
            self._log.debug(
                f"c8.gcs.statustext_failed: {exc!r}",
                extra={
                    "kind": "c8.gcs.statustext_failed",
                    "kv": {"error": repr(exc)},
                },
            )

    # ------------------------------------------------------------------
    # Internals

    def _enforce_single_writer(self) -> None:
        cur = threading.get_ident()
        if self._open_emit_thread_ident is None:
            self._open_emit_thread_ident = cur
            return
        if self._open_emit_thread_ident != cur:
            raise RuntimeError(
                "QgcTelemetryAdapter outbound is single-writer; "
                f"first thread={self._open_emit_thread_ident}, this thread={cur}"
            )

    def _connect(self, port: PortConfig) -> Any:
        if self._connect_factory is not None:
            return self._connect_factory(port.device, port.baud)
        from pymavlink import mavutil  # lazy import per ADR-002

        return mavutil.mavlink_connection(
            port.device,
            baud=port.baud,
            dialect="common",
            mavlink_version="2.0",
        )

    def _ensure_operator_handler_attached(self) -> None:
        with self._operator_lock:
            if self._operator_handler_attached or self._connection is None:
                return
            register = getattr(self._connection, "message_hooks", None)
            if register is None or not isinstance(register, list):
                # The injected stub may expose a `register_message_hook`
                # callable; honour that too for test ergonomics.
                hook_register = getattr(self._connection, "register_message_hook", None)
                if callable(hook_register):
                    hook_register(self._on_inbound_message)
                else:
                    return
            else:
                register.append(self._on_inbound_message)
            self._operator_handler_attached = True

    def _on_inbound_message(self, *args: Any) -> None:
        """Pymavlink message-hook entrypoint.

        pymavlink calls hooks with ``(self, conn, msg)`` for instance
        hooks or ``(conn, msg)`` for free-function hooks; the message
        is always the last positional argument.
        """
        if not args:
            return
        msg = args[-1]
        try:
            msg_type = msg.get_type() if hasattr(msg, "get_type") else str(type(msg).__name__)
        except Exception:
            return
        if msg_type not in _DEFAULT_OPERATOR_COMMAND_TYPES:
            return
        cmd = self._translate_to_operator_command(msg_type, msg)
        self._operator_bus.dispatch(cmd)
        self._record_operator_command_fdr(cmd, msg)

    def _translate_to_operator_command(self, msg_type: str, msg: Any) -> OperatorCommand:
        payload: dict[str, str | int | float | bool] = {}
        for attr in (
            "param_id",
            "param_value",
            "command",
            "result",
            "target_system",
            "target_component",
        ):
            if hasattr(msg, attr):
                value = getattr(msg, attr)
                if isinstance(value, (str, int, float, bool)):
                    payload[attr] = value
                elif isinstance(value, bytes):
                    try:
                        payload[attr] = value.decode("utf-8", errors="replace")
                    except Exception:
                        payload[attr] = value.hex()
        return OperatorCommand(
            command=msg_type,
            payload=payload,
            received_at=self._clock.monotonic_ns(),
        )

    def _record_operator_command_fdr(self, cmd: OperatorCommand, msg: Any) -> None:
        record = FdrRecord(
            schema_version=1,
            ts=datetime.now(tz=timezone.utc).isoformat(),
            producer_id="c8_gcs_adapter",
            kind="log",
            payload={
                "level": "INFO",
                "component": "c8_gcs_adapter",
                "kind": "c8.gcs.operator_command",
                "msg": "c8.gcs.operator_command",
                "kv": {
                    "command": cmd.command,
                    "payload": cmd.payload,
                    "source_system": int(getattr(msg, "get_srcSystem", lambda: 0)() or 0),
                },
            },
        )
        try:
            self._fdr_client.enqueue(record)
        except Exception as exc:
            self._log.debug(
                f"c8.gcs.operator_command_fdr_enqueue_failed: {exc!r}",
                extra={
                    "kind": "c8.gcs.operator_command_fdr_enqueue_failed",
                    "kv": {"error": repr(exc)},
                },
            )

    def _extract_wgs84(self, output: EstimatorOutput) -> LatLonAlt:
        wgs = output.position_wgs84
        if not isinstance(wgs, LatLonAlt):
            raise GcsEmitError(
                f"EstimatorOutput.position_wgs84 must be a LatLonAlt; got {type(wgs).__name__}"
            )
        return wgs

    def _clock_ms_boot(self) -> int:
        return self._clock.monotonic_ns() // 1_000_000
