//! Cross-transport document translator
//! (peat-mesh ADR-0011 → peat ADR-059, the canonical decision record).
//!
//! Each non-iroh transport (BLE, TAK, LoRa, SBD, mavlink, …) implements
//! [`Translator`] to bridge documents between the mesh's
//! [`Node`](crate::node::Node) document store and the transport's
//! foreign wire format. iroh is *not* a translator — it is the CRDT
//! sync substrate that ships Automerge ops between Node doc stores
//! directly.
//!
//! See `docs/adr/0011-cross-transport-document-bridging.md` (and the
//! canonical [`peat/docs/adr/059-cross-transport-document-bridging.md`](https://github.com/defenseunicorns/peat/blob/main/docs/adr/059-cross-transport-document-bridging.md))
//! for the full architecture: trait shape, [`TransportManager`] fan-out
//! orchestration, origin tagging via
//! [`ChangeEvent.origin`](crate::sync::types::ChangeEvent), per-doc
//! [`allowed_transports`] scoping (Slice 2), and the
//! `requires_delete_propagation` collection-registration gate.
//!
//! [`TransportManager`]: crate::transport::manager::TransportManager
//! [`allowed_transports`]: https://github.com/defenseunicorns/peat/blob/main/docs/adr/059-cross-transport-document-bridging.md

use async_trait::async_trait;

use crate::sync::types::Document;

/// Per-call context passed to translator encode/decode methods.
///
/// Carries the per-event information today's `BleTranslator` already
/// takes as positional arguments (callsign, cell_id, peripheral_id) and
/// that ADR-029's TAK adapter plumbs through its config (identity,
/// callsign). Adding fields here is non-breaking: translators ignore
/// fields they don't need.
///
/// **Construct via [`TranslationContext::outbound`] or
/// [`TranslationContext::inbound`]** rather than struct-literal
/// syntax — the field set is `#[non_exhaustive]` so callsites that
/// build the struct directly will break the moment a field is added.
/// The named constructors give the orchestrator and tests one shape
/// to share, and the with-`*` builder methods chain ergonomically.
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct TranslationContext {
    /// Local node's callsign (operator-visible name).
    pub local_callsign: Option<String>,
    /// Local node's *peer-facing wire identifier* on this transport
    /// (e.g. peripheral_id for BLE, EndpointId hex for iroh, callsign
    /// for TAK).
    ///
    /// Distinct from [`Translator::transport_id`], which names the
    /// transport family itself (`"ble"`, `"iroh"`, `"tak"`). This
    /// field is the *node's own address as broadcast peers see it*
    /// on the wire — the orchestrator already knows the transport
    /// family from registration; it does not need to pass that here.
    pub local_wire_id: Option<String>,
    /// Cell / formation the document belongs to, if scoped.
    pub cell_id: Option<String>,
    /// Document collection (`"tracks"`, `"platforms"`, `"alerts"`,
    /// `"canned_messages"`, etc.) the event came from.
    ///
    /// **Outbound** (`encode_outbound`): set by `TransportManager`'s
    /// fan-out from the originating `ChangeEvent.collection`.
    ///
    /// **Inbound** (`decode_inbound`): set by the transport-side
    /// bridge **before** it calls `decode_inbound` —
    /// `TranslationContext::inbound(peer).with_collection(...)`. The
    /// same bridge then routes the decoded `Document` through
    /// `Node::publish_with_origin`, which runs *after*
    /// `decode_inbound` returns and so cannot populate the context
    /// the codec already read.
    ///
    /// Codecs whose outbound encoding depends on doc type — e.g.
    /// `BleTranslator` dispatches to `position_to_track` vs
    /// `peripheral_to_platform` based on collection — read this
    /// field in `encode_outbound`. Codecs that don't care can ignore
    /// it.
    pub collection: Option<String>,
    /// Peer involved in this call.
    ///
    /// - **Inbound** (`decode_inbound`): the peer that delivered the
    ///   bytes. Stamped by `TransportManager`'s inbound bridge so the
    ///   codec can attribute the source.
    /// - **Outbound** (`encode_outbound`): `None` if the document is
    ///   broadcast to all reachable peers on this transport, or the
    ///   single targeted peer per ADR-046 `target_nodes`.
    ///
    /// **Target-set enforcement is the orchestrator's responsibility,
    /// not the codec's.** Per ADR-059 §"Composes with ADR-046", when
    /// an outbound document has `target_nodes` set, `TransportManager`
    /// is expected to filter the per-transport peer set against
    /// `target_nodes` and either skip the encode entirely (if no
    /// reachable target intersects) or populate `peer` for each
    /// targeted enqueue. Codecs should treat `peer` as advisory and
    /// not re-enforce reachability internally.
    pub peer: Option<String>,
}

impl TranslationContext {
    /// Build a context for an outbound `encode_outbound` call.
    /// The orchestrator constructs one of these per fan-out cycle;
    /// codecs can supplement via `with_*` builders.
    pub fn outbound() -> Self {
        Self::default()
    }

    /// Build a context for an inbound `decode_inbound` call,
    /// stamping the source peer that delivered the bytes.
    pub fn inbound(from_peer: impl Into<String>) -> Self {
        Self {
            peer: Some(from_peer.into()),
            ..Self::default()
        }
    }

    /// Set the local node's callsign on the context. Builder-style;
    /// chains.
    pub fn with_callsign(mut self, callsign: impl Into<String>) -> Self {
        self.local_callsign = Some(callsign.into());
        self
    }

    /// Set the cell / formation this context belongs to.
    pub fn with_cell(mut self, cell_id: impl Into<String>) -> Self {
        self.cell_id = Some(cell_id.into());
        self
    }

    /// Set the local node's peer-facing wire identifier on this
    /// transport (e.g. BLE peripheral_id, iroh EndpointId hex).
    pub fn with_local_wire_id(mut self, id: impl Into<String>) -> Self {
        self.local_wire_id = Some(id.into());
        self
    }

    /// Set the peer involved in this call. For outbound, this is the
    /// targeted peer (ADR-046 `target_nodes`); the orchestrator
    /// populates it. Inbound callers should prefer
    /// [`TranslationContext::inbound`] which sets `peer` at
    /// construction time.
    pub fn with_peer(mut self, peer: impl Into<String>) -> Self {
        self.peer = Some(peer.into());
        self
    }

    /// Set the document collection the event came from. Populated by
    /// `TransportManager`'s fan-out from `ChangeEvent`'s
    /// `collection` field; codecs use it in `encode_outbound` to
    /// dispatch by doc type.
    pub fn with_collection(mut self, collection: impl Into<String>) -> Self {
        self.collection = Some(collection.into());
        self
    }
}

/// Wire-format codec for one transport.
///
/// Translators are pure-ish codec libraries: they own their internal
/// state (callsign caches, session symbol tables, etc.) but they don't
/// own the BLE radio, the iroh socket, the LoRa modem, etc. — the
/// underlying transport layer (peat-btle, peat-mesh's iroh transport,
/// future LoRa/SBD transports) does that. `TransportManager`
/// orchestrates: it observes `Node`'s collections, applies origin and
/// `allowed_transports` filters, and hands eligible documents to each
/// registered translator's `encode_outbound`. Inbound traffic flows
/// the other way: a transport calls `decode_inbound` on its registered
/// translator and routes the result through
/// [`Node::publish_with_origin`](crate::node::Node::publish_with_origin).
///
/// **iroh is not a `Translator` impl.** iroh is the CRDT sync substrate
/// for the doc store itself; its "encoding" is Automerge's own
/// op-stream format, which the Node backend handles natively.
///
/// **Concurrency model.** Methods take `&self`, not `&mut self`. The
/// orchestrator hands the same `Arc<dyn Translator>` to multiple
/// per-transport drain tasks (and to the inbound transport bridge),
/// so any mutable state inside an impl uses interior mutability —
/// `Arc<RwLock<...>>` for caches, `parking_lot::Mutex<...>` for hot
/// paths, atomic counters for stats. This matches today's
/// `BleTranslator` shape (a callsign cache lives behind `RwLock`)
/// and the TAK adapter sketch in ADR-029. The trait is `Send + Sync`
/// so impls can be shared across drain tasks safely.
///
/// **Invariants** (enforced by `TransportManager` at registration
/// time):
/// - Every translator's [`transport_id`](Translator::transport_id)
///   must be unique within a single `TransportManager` registration.
///   The manager rejects collisions.
/// - `transport_id` returns `&'static str` — the ID must be a
///   compile-time-stable literal (e.g. `const BLE_ID: &str = "ble";`).
///   This enforces ADR-059's "stable string" requirement at the
///   type level: registration-order indices, runtime-constructed
///   IDs, and per-instance suffixes are all rejected by the type
///   system, not just by the string-equality check at runtime.
#[async_trait]
pub trait Translator: Send + Sync {
    /// Stable identifier for this transport, used for origin tagging,
    /// `allowed_transports` matching, and wire-format identity.
    /// Examples: `"ble"`, `"tak"`, `"lora"`, `"sbd"`.
    ///
    /// Returns `&'static str` so the ID must be a compile-time
    /// literal — runtime-constructed IDs, per-instance suffixes, and
    /// registration-order indices are explicitly disallowed (they
    /// would break the wire-format round-trip property ADR-059
    /// depends on).
    fn transport_id(&self) -> &'static str;

    /// Encode a document for outbound transmission on this transport.
    ///
    /// `None` is a *decline*, not an error: the codec cannot carry this
    /// document on this transport (size limits, missing fields, format
    /// affinity per ADR-038, ADR-046 `target_nodes` not reachable on
    /// this transport's peer set, etc.). The orchestrator treats every
    /// `None` uniformly as "no bytes to send" and skips the send
    /// without diagnostic — declines are normal traffic.
    ///
    /// **Real codec errors must be logged and metric'd inside the
    /// implementation before returning `None`.** The trait's signature
    /// can't carry an `Err` for outbound (the orchestrator wouldn't
    /// know what to do with it that the codec couldn't already do),
    /// but silently swallowing a real encode failure with the same
    /// `None` used for declines means wire-format drift on the encode
    /// side is invisible to operators. Codecs MUST distinguish at
    /// telemetry: `metrics::counter!("translator.encode_decline", "transport"=>id)`
    /// for normal declines, `metrics::counter!("translator.encode_error", "transport"=>id)`
    /// for failures, with a `tracing::warn!` on the error path.
    async fn encode_outbound(&self, doc: &Document, ctx: &TranslationContext) -> Option<Vec<u8>>;

    /// Decode an inbound wire-format payload into a document.
    ///
    /// `Ok(None)` means the payload is well-formed but not a document
    /// this transport carries (e.g. a control frame, a heartbeat) —
    /// normal traffic, no diagnostic needed. `Err(_)` means the bytes
    /// were malformed for this codec — `TransportManager`'s inbound
    /// bridge bumps a per-transport parse-error metric so production
    /// fleets can detect wire-format drift.
    ///
    /// Error type is `anyhow::Error` deliberately: codecs own their
    /// own error enums (CoT for TAK, decode errors for BLE, etc.) and
    /// a closed enum here would force peat-mesh to import every
    /// codec's error types, inverting the layering ADR-059 exists to
    /// clean up.
    async fn decode_inbound(
        &self,
        bytes: &[u8],
        ctx: &TranslationContext,
    ) -> anyhow::Result<Option<Document>>;
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Locks the `&'static str` return: this test wouldn't compile if
    /// `transport_id` returned `&str` tied to `&self`, because the
    /// const can't outlive `&self` under a non-static return type.
    /// The shape decision is captured in code, not just docs.
    struct StubTranslator;

    #[async_trait]
    impl Translator for StubTranslator {
        fn transport_id(&self) -> &'static str {
            "stub"
        }
        async fn encode_outbound(
            &self,
            _doc: &Document,
            _ctx: &TranslationContext,
        ) -> Option<Vec<u8>> {
            None
        }
        async fn decode_inbound(
            &self,
            _bytes: &[u8],
            _ctx: &TranslationContext,
        ) -> anyhow::Result<Option<Document>> {
            Ok(None)
        }
    }

    #[test]
    fn translation_context_constructors_set_expected_fields() {
        let outbound = TranslationContext::outbound()
            .with_callsign("ALPHA-1")
            .with_cell("BRAVO");
        assert_eq!(outbound.local_callsign.as_deref(), Some("ALPHA-1"));
        assert_eq!(outbound.cell_id.as_deref(), Some("BRAVO"));
        assert!(outbound.peer.is_none());

        let inbound = TranslationContext::inbound("peer-XYZ");
        assert_eq!(inbound.peer.as_deref(), Some("peer-XYZ"));
        assert!(inbound.local_callsign.is_none());

        // with_peer for the outbound-targeted case (ADR-046 composition).
        let targeted = TranslationContext::outbound().with_peer("target-ABC");
        assert_eq!(targeted.peer.as_deref(), Some("target-ABC"));
    }

    #[test]
    fn transport_id_is_static_str() {
        // If transport_id ever drifts from &'static str (e.g. someone
        // proposes &str tied to &self for a "dynamic id" use case),
        // this assignment fails to compile — the test guards the
        // signature, not just the value.
        let t = StubTranslator;
        let id: &'static str = t.transport_id();
        assert_eq!(id, "stub");
    }
}
