Skip to main content

offline_protocol/
transport_manager.rs

1//! Transport management for multi-transport protocol.
2//!
3//! This module provides the TransportManager which manages multiple transports
4//! and uses DORS (Dynamic Offline Relay Switch) to select the optimal transport
5//! for each message.
6
7use crate::constants::{HOP_COUNT_EMA_ALPHA, LATENCY_EMA_ALPHA, OBSERVED_STATS_COMPACT_THRESHOLD};
8use crate::events::{DorsEscalationPhase, DorsEscalationReasonCode, DorsReasonCode, Event};
9use crate::telemetry::routing::{RoutingDecision, RoutingPhase, RoutingReasonCode};
10use crate::{Error, Result};
11use chrono::Utc;
12use offline_protocol_core::{Message, WireCodec};
13use offline_protocol_router::{
14    display_routing_score, DorsConfig, EscalationTriggerReason, TransportScore, TransportSelector,
15};
16use offline_protocol_transport::{
17    Error as TransportError, Transport, TransportMetrics, TransportStatus, TransportType,
18};
19use std::collections::{HashMap, HashSet};
20use std::sync::Arc;
21use std::time::Instant;
22use tracing::{debug, warn};
23
24/// Manages multiple transports and handles transport selection.
25pub struct TransportManager {
26    /// Available transports mapped by type.
27    ///
28    /// Transports are fully interior-mutable (every `Transport` method takes
29    /// `&self`), so they are shared as plain `Arc`s — no outer mutex
30    /// serializing sends against status/metrics reads.
31    transports: HashMap<TransportType, Arc<dyn Transport>>,
32
33    /// Current active transport.
34    current_transport: Option<TransportType>,
35
36    /// Transport selector (DORS).
37    selector: TransportSelector,
38
39    /// Locally observed delivery outcomes used to enrich transport metrics.
40    observations: HashMap<TransportType, ObservedStats>,
41
42    /// Optional callback for DORS lifecycle/decision events (OFF-258).
43    dors_event_callback: Option<Arc<dyn Fn(Event) + Send + Sync>>,
44
45    /// Optional callback for structured routing decisions. Fires alongside
46    /// every legacy `dors_event_callback` emission so the telemetry sink
47    /// receives the richer `RoutingDecision` shape while `EventCallback`
48    /// consumers continue to see the flattened `Event::Dors*` variants.
49    routing_decision_callback: Option<Arc<dyn Fn(RoutingDecision) + Send + Sync>>,
50
51    /// When `true`, every emitted [`RoutingDecision`] carries the full
52    /// per-transport [`TransportScore`] breakdown in its `scores` field.
53    /// Mirrors `TelemetryConfig::routing_diagnostic` and is toggled by
54    /// `OfflineProtocol::install_telemetry_sink`.
55    routing_diagnostic: bool,
56
57    /// Last escalation trigger event emitted (reason, time) for dedupe window.
58    last_escalation_trigger_emitted: Option<(DorsEscalationReasonCode, std::time::Instant)>,
59
60    /// Peers known to decode the binary wire codec (from their signed key
61    /// package). Messages addressed to a listed peer are stamped
62    /// [`WireCodec::BinaryV1`] at send; all others stay JSON.
63    peer_binary_wire: HashSet<String>,
64}
65
66/// Carriers whose links are peer-to-peer, in the order a forwarding caller
67/// should prefer them.
68///
69/// These are the only transports that can carry a *mesh hop*: a frame handed
70/// straight to a neighbor's radio. Wi-Fi Direct leads because a link that
71/// exists at all is the higher-bandwidth one; BLE is the carrier that is
72/// almost always present. Everything else reaches peers through
73/// infrastructure, which is not a hop — it is an exit from the mesh.
74const MESH_TRANSPORTS: &[TransportType] = &[TransportType::WiFiDirect, TransportType::BLE];
75
76/// A peer reachable over a direct mesh link.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct MeshNeighbor {
79    /// User-level id of the neighbor.
80    pub peer_id: String,
81    /// Signal strength for the link, when the carrier measures one.
82    pub rssi: Option<i16>,
83    /// The carrier holding the link.
84    pub transport: TransportType,
85}
86
87impl MeshNeighbor {
88    /// How good this link is, on a 0–100 scale.
89    ///
90    /// Derived from signal strength where the carrier reports it, and a
91    /// mid-scale value where it does not — an unmeasured link should not
92    /// outrank a measured good one, nor be discarded like a measured bad one.
93    pub fn link_quality(&self) -> u8 {
94        match self.rssi {
95            Some(rssi) => offline_protocol_transport::LinkQuality::from_rssi(rssi).value(),
96            None => offline_protocol_transport::DEFAULT_LINK_QUALITY_WITHOUT_RSSI,
97        }
98    }
99}
100
101/// Dedupe window: don't re-emit same escalation trigger reason within this duration.
102const ESCALATION_TRIGGER_DEDUPE_SECS: u64 = 30;
103
104/// Cap on the per-peer binary-wire capability set. Keyed by the wire-claimed
105/// peer id, so — like `key_package_sent_to` — it is cleared at capacity to bound
106/// a forged-sender flood; the only cost of forgetting a peer is re-learning its
107/// capability from the next key package (one JSON send in the meantime).
108const MAX_PEER_BINARY_WIRE_ENTRIES: usize = 4096;
109
110#[derive(Debug, Default, Clone)]
111struct ObservedStats {
112    success_count: u32,
113    failure_count: u32,
114    latency_ema: Option<f32>,
115    hop_ema: Option<f32>,
116    last_success_at: Option<Instant>,
117}
118
119impl ObservedStats {
120    fn record_success(&mut self, latency_ms: u32, hop_count: u8) {
121        self.success_count = self.success_count.saturating_add(1);
122        self.latency_ema = Some(update_ema(
123            self.latency_ema,
124            latency_ms as f32,
125            LATENCY_EMA_ALPHA,
126        ));
127        self.hop_ema = Some(update_ema(
128            self.hop_ema,
129            hop_count as f32,
130            HOP_COUNT_EMA_ALPHA,
131        ));
132        self.last_success_at = Some(Instant::now());
133        self.compact();
134    }
135
136    fn record_failure(&mut self) {
137        self.failure_count = self.failure_count.saturating_add(1);
138        self.compact();
139    }
140
141    fn compact(&mut self) {
142        let total = self.success_count.saturating_add(self.failure_count);
143        if total > OBSERVED_STATS_COMPACT_THRESHOLD {
144            self.success_count /= 2;
145            self.failure_count /= 2;
146        }
147    }
148
149    fn delivery_ratio(&self) -> Option<f32> {
150        let total = self.success_count + self.failure_count;
151        if total == 0 {
152            None
153        } else {
154            Some(self.success_count as f32 / total as f32)
155        }
156    }
157
158    fn drop_ratio(&self) -> Option<f32> {
159        self.delivery_ratio()
160            .map(|ratio| (1.0 - ratio).clamp(0.0, 1.0))
161    }
162
163    fn apply_to_metrics(&self, metrics: &mut TransportMetrics) {
164        if self.success_count + self.failure_count > 0 {
165            // Only override delivery counts when the transport itself has not
166            // reported any.  Internet transport tracks wire-level outcomes via
167            // the confirmation loop; overwriting those would discard real data.
168            //
169            // NOTE: This check is value-based, not transport-type-based.  If
170            // BLE or WiFi Direct ever start reporting their own success/failure
171            // counts from the native layer (the way Internet does via the
172            // confirmation loop), the condition below will silently skip the
173            // local observations for those transports too.  At that point,
174            // consider switching to an explicit per-transport-type check.
175            if metrics.success_count + metrics.failure_count == 0 {
176                metrics.success_count = self.success_count;
177                metrics.failure_count = self.failure_count;
178                metrics.delivery_ratio = self.delivery_ratio();
179                metrics.drop_rate = self.drop_ratio();
180            }
181        }
182
183        if let Some(latency) = self.latency_ema {
184            metrics.latency_ms = Some(latency.round().clamp(0.0, u32::MAX as f32) as u32);
185        }
186
187        if let Some(hop) = self.hop_ema {
188            metrics.average_hop_count = Some(hop);
189        }
190    }
191}
192
193fn update_ema(current: Option<f32>, new_value: f32, alpha: f32) -> f32 {
194    let alpha = alpha.clamp(0.0, 1.0);
195    match current {
196        Some(existing) => existing * (1.0 - alpha) + new_value * alpha,
197        None => new_value,
198    }
199}
200
201impl TransportManager {
202    /// Creates a new transport manager with default configuration.
203    pub fn new(selector: TransportSelector) -> Self {
204        Self {
205            transports: HashMap::new(),
206            current_transport: None,
207            selector,
208            observations: HashMap::new(),
209            dors_event_callback: None,
210            routing_decision_callback: None,
211            routing_diagnostic: false,
212            last_escalation_trigger_emitted: None,
213            peer_binary_wire: HashSet::new(),
214        }
215    }
216
217    /// Returns whether a peer is recorded as able to decode the binary wire
218    /// codec (and would therefore be sent binary frames).
219    pub fn peer_supports_binary_wire(&self, peer_id: &str) -> bool {
220        self.peer_binary_wire.contains(peer_id)
221    }
222
223    /// Records whether a peer can decode our binary wire frames, learned from
224    /// its signed key package. Messages addressed to a capable peer are stamped
225    /// [`WireCodec::BinaryV1`] at send; all others stay JSON.
226    pub fn mark_peer_binary_wire(&mut self, peer_id: &str, supported: bool) {
227        if supported {
228            // Bound the wire-keyed set: clear at capacity, then insert.
229            if !self.peer_binary_wire.contains(peer_id)
230                && self.peer_binary_wire.len() >= MAX_PEER_BINARY_WIRE_ENTRIES
231            {
232                self.peer_binary_wire.clear();
233            }
234            self.peer_binary_wire.insert(peer_id.to_string());
235        } else {
236            self.peer_binary_wire.remove(peer_id);
237        }
238    }
239
240    /// Forwards a peer's advertised Nostr public key to the Nostr transport, so
241    /// gift wraps addressed to them are sealed to a key only their install
242    /// holds instead of to the publicly computable bootstrap key.
243    ///
244    /// `None` means "this peer advertises no Nostr key", which **forgets** any
245    /// key already held rather than leaving it in place. That is what keeps the
246    /// in-memory copy consistent with the durable record, whose downgrade
247    /// semantics delete on an empty advertisement: retaining it here instead
248    /// would keep sealing to a key the peer has stopped claiming, and survive
249    /// only until the next restart — a divergence with no symptom.
250    ///
251    /// A no-op when the Nostr transport is not installed (the common case —
252    /// `nostr_enabled` defaults off). The bound on the underlying map lives in
253    /// the transport.
254    pub fn mark_peer_nostr_pubkey(&mut self, peer_id: &str, pubkey_hex: Option<&str>) {
255        let Some(transport) = self.transports.get(&TransportType::Nostr) else {
256            return;
257        };
258        if let Some(nostr) = transport
259            .as_any()
260            .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
261        {
262            match pubkey_hex {
263                Some(pubkey_hex) => nostr.set_peer_nostr_pubkey(peer_id, pubkey_hex),
264                None => nostr.forget_peer_nostr_pubkey(peer_id),
265            }
266        }
267    }
268
269    /// Forgets a peer's Nostr public key, so frames to them revert to the
270    /// publicly computable bootstrap key.
271    ///
272    /// Used by the unblock clean slate, which declares everything learned about
273    /// a peer stale. Reverting is the right fallback rather than refusing to
274    /// send: the bootstrap key is derived from the peer's user id, so it works
275    /// whatever install they are on now.
276    pub fn clear_peer_nostr_pubkey(&mut self, peer_id: &str) {
277        if let Some(nostr) = self.transports.get(&TransportType::Nostr).and_then(|t| {
278            t.as_any()
279                .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
280        }) {
281            nostr.forget_peer_nostr_pubkey(peer_id);
282        }
283    }
284
285    /// This install's Nostr public key, for advertising in outgoing key
286    /// packages. `None` when the Nostr transport is not installed.
287    pub fn nostr_public_key(&self) -> Option<String> {
288        self.transports
289            .get(&TransportType::Nostr)?
290            .as_any()
291            .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
292            .map(|nostr| nostr.public_key_hex())
293    }
294
295    /// Applies the sealed-envelope kill switch to the Nostr transport.
296    pub fn set_nostr_sealing_enabled(&mut self, enabled: bool) {
297        if let Some(nostr) = self.transports.get(&TransportType::Nostr).and_then(|t| {
298            t.as_any()
299                .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
300        }) {
301            nostr.set_sealing_enabled(enabled);
302        }
303    }
304
305    /// Applies the cold-contact kill switch to the Nostr transport.
306    pub fn set_nostr_cold_contact_enabled(&mut self, enabled: bool) {
307        if let Some(nostr) = self.nostr_transport() {
308            nostr.set_cold_contact_enabled(enabled);
309        }
310    }
311
312    /// Queues a key-package record for publication at `slot_id`.
313    ///
314    /// A no-op when the Nostr transport is not installed, which is the common
315    /// case: publication is driven from the engine's tick, and that tick runs
316    /// whether or not Nostr is enabled.
317    ///
318    /// Serializes through the transport's own chokepoint, which yields JSON for
319    /// an unstamped message — and a published record must never be anything
320    /// else. The binary codec is negotiated per peer from that peer's key
321    /// package, and the reader of this record is by definition a stranger whose
322    /// key package we have not seen. Emitting binary here would make the record
323    /// unreadable by exactly the audience it exists for.
324    pub fn publish_nostr_key_package(&mut self, slot_id: &str, message: &Message) -> Result<()> {
325        let Some(nostr) = self.nostr_transport() else {
326            return Ok(());
327        };
328        let payload = nostr.serialize_message(message)?;
329        nostr.publish_key_package(slot_id, payload);
330        Ok(())
331    }
332
333    /// Drains the slot ids whose publication never reached a relay.
334    ///
335    /// Empty when Nostr is not installed, which keeps the caller's tick free of
336    /// a transport check it would otherwise need.
337    pub fn take_failed_nostr_publications(&self) -> Vec<String> {
338        self.nostr_transport()
339            .map(|nostr| nostr.take_failed_publications())
340            .unwrap_or_default()
341    }
342
343    /// Whether the Nostr transport is installed and publishing records.
344    pub fn nostr_cold_contact_active(&self) -> bool {
345        self.nostr_transport()
346            .map(|nostr| nostr.cold_contact_enabled())
347            .unwrap_or(false)
348    }
349
350    fn nostr_transport(&self) -> Option<&offline_protocol_transport::nostr::NostrTransport> {
351        self.transports.get(&TransportType::Nostr).and_then(|t| {
352            t.as_any()
353                .downcast_ref::<offline_protocol_transport::nostr::NostrTransport>()
354        })
355    }
356
357    /// Sets the callback for DORS decision events (dors_score_updated, dors_transport_selected,
358    pub fn set_dors_event_callback(&mut self, callback: Option<Arc<dyn Fn(Event) + Send + Sync>>) {
359        self.dors_event_callback = callback;
360    }
361
362    /// Sets the callback that receives structured
363    /// [`crate::telemetry::RoutingDecision`] records.
364    ///
365    /// Fires at the same sites as `dors_event_callback`, carrying a richer
366    /// shape suitable for the unified telemetry sink.
367    ///
368    /// Crate-private: apps must drive routing telemetry through
369    /// [`crate::OfflineProtocol::install_telemetry_sink`] rather than wiring
370    /// the callback directly. This keeps [`crate::telemetry::TelemetryConfig`]
371    /// the single source of truth for sink configuration.
372    pub(crate) fn set_routing_decision_callback(
373        &mut self,
374        callback: Option<Arc<dyn Fn(RoutingDecision) + Send + Sync>>,
375    ) {
376        self.routing_decision_callback = callback;
377    }
378
379    /// Enables or disables the routing-diagnostic detail level.
380    ///
381    /// When `true`, [`RoutingDecision::scores`] is populated with the full
382    /// seven-factor [`TransportScore`] for every ranked transport on every
383    /// emission. When `false` (default), the vector is empty and the hot
384    /// path avoids the per-emission allocation. Mirrors
385    /// `TelemetryConfig::routing_diagnostic`.
386    ///
387    /// Crate-private: see [`Self::set_routing_decision_callback`].
388    pub(crate) fn set_routing_diagnostic(&mut self, enabled: bool) {
389        self.routing_diagnostic = enabled;
390    }
391
392    fn emit_dors_event(&self, event: Event) {
393        if let Some(ref cb) = self.dors_event_callback {
394            cb(event);
395        }
396    }
397
398    /// Lazy-emit a [`RoutingDecision`]. The `build` closure only runs when a
399    /// callback is actually installed, so the no-sink path pays only an
400    /// `Option::is_some` check — not a `Utc::now()` call and struct build.
401    fn emit_routing_decision<F: FnOnce() -> RoutingDecision>(&self, build: F) {
402        if let Some(ref cb) = self.routing_decision_callback {
403            cb(build());
404        }
405    }
406
407    fn escalation_trigger_reason_to_code(r: EscalationTriggerReason) -> DorsEscalationReasonCode {
408        match r {
409            EscalationTriggerReason::RetryThreshold => DorsEscalationReasonCode::RetryThreshold,
410            EscalationTriggerReason::PoorSignal => DorsEscalationReasonCode::PoorSignal,
411            EscalationTriggerReason::Congestion => DorsEscalationReasonCode::Congestion,
412            EscalationTriggerReason::LowTtl => DorsEscalationReasonCode::LowTtl,
413            EscalationTriggerReason::LowSuccessRate => DorsEscalationReasonCode::LowSuccessRate,
414        }
415    }
416
417    fn dors_reason_code_to_routing(code: DorsReasonCode) -> RoutingReasonCode {
418        match code {
419            DorsReasonCode::InitialSelection => RoutingReasonCode::InitialSelection,
420            DorsReasonCode::PrimarySelected => RoutingReasonCode::PrimarySelected,
421            DorsReasonCode::PrimarySuccess => RoutingReasonCode::PrimarySuccess,
422            DorsReasonCode::FallbackSuccess => RoutingReasonCode::FallbackSuccess,
423            DorsReasonCode::EscalationApplied => RoutingReasonCode::EscalationApplied,
424            DorsReasonCode::CurrentUnavailable => RoutingReasonCode::CurrentUnavailable,
425        }
426    }
427
428    fn escalation_reason_code_to_routing(code: DorsEscalationReasonCode) -> RoutingReasonCode {
429        match code {
430            DorsEscalationReasonCode::FallbackSuccess => RoutingReasonCode::FallbackSuccess,
431            DorsEscalationReasonCode::RetryThreshold => RoutingReasonCode::RetryThreshold,
432            DorsEscalationReasonCode::PoorSignal => RoutingReasonCode::PoorSignal,
433            DorsEscalationReasonCode::Congestion => RoutingReasonCode::Congestion,
434            DorsEscalationReasonCode::LowTtl => RoutingReasonCode::LowTtl,
435            DorsEscalationReasonCode::LowSuccessRate => RoutingReasonCode::LowSuccessRate,
436        }
437    }
438
439    /// Builds a [`RoutingDecision`]. The `detailed_scores` slice is cloned
440    /// into `scores` when `routing_diagnostic` is enabled on the manager;
441    /// otherwise the vector stays empty (allocation-free).
442    ///
443    /// Takes `now_ms` by argument rather than calling `Utc::now()` per
444    /// build so every decision emitted from a single `send()` cycle shares
445    /// one timestamp — easier to correlate downstream and saves three to
446    /// four syscalls on the send hot path.
447    #[allow(clippy::too_many_arguments)]
448    fn build_routing_decision(
449        &self,
450        now_ms: i64,
451        phase: RoutingPhase,
452        from: Option<TransportType>,
453        to: Option<TransportType>,
454        winning_score: Option<f32>,
455        reason_code: Option<RoutingReasonCode>,
456        detailed_scores: Option<&[(TransportType, TransportScore)]>,
457    ) -> RoutingDecision {
458        // Sanitize the internal fallback-demotion sentinel out of every score
459        // that leaves the engine. DORS keeps Internet's `total` negative to rank
460        // it last; telemetry/UniFFI consumers must see the real 0–125 quality
461        // score instead. This is the single chokepoint for all RoutingDecision
462        // emitters, so `winning_score` is also sanitized here defensively
463        // (idempotent for an already-positive value).
464        let scores = match (self.routing_diagnostic, detailed_scores) {
465            (true, Some(detailed)) => detailed
466                .iter()
467                .map(|(t, s)| {
468                    let mut s = s.clone();
469                    s.total = display_routing_score(s.total);
470                    (*t, s)
471                })
472                .collect(),
473            _ => Vec::new(),
474        };
475        RoutingDecision {
476            timestamp_ms: now_ms,
477            phase,
478            from,
479            to,
480            winning_score: winning_score.map(display_routing_score),
481            reason_code,
482            scores,
483        }
484    }
485
486    /// Emit dors_escalation_triggered at trigger boundary (typed reason), deduped by reason + time window.
487    fn emit_escalation_trigger_if_deduped(
488        &mut self,
489        now_ms: i64,
490        reason_code: DorsEscalationReasonCode,
491        from: TransportType,
492        to: TransportType,
493        reason_detail: Option<String>,
494        detailed_scores: Option<&[(TransportType, TransportScore)]>,
495    ) {
496        let now = std::time::Instant::now();
497        let emit = match &self.last_escalation_trigger_emitted {
498            Some((last_code, last_at)) => {
499                *last_code != reason_code
500                    || last_at.elapsed().as_secs() >= ESCALATION_TRIGGER_DEDUPE_SECS
501            }
502            None => true,
503        };
504        if emit {
505            self.last_escalation_trigger_emitted = Some((reason_code, now));
506            self.emit_dors_event(Event::dors_escalation_triggered(
507                DorsEscalationPhase::Triggered,
508                from.to_string(),
509                to.to_string(),
510                reason_code,
511                reason_detail,
512            ));
513            self.emit_routing_decision(|| {
514                self.build_routing_decision(
515                    now_ms,
516                    RoutingPhase::Escalated,
517                    Some(from),
518                    Some(to),
519                    None,
520                    Some(Self::escalation_reason_code_to_routing(reason_code)),
521                    detailed_scores,
522                )
523            });
524        }
525    }
526
527    /// Adds a transport to the manager.
528    ///
529    /// # Arguments
530    ///
531    /// * `transport_type` - Type of transport to add
532    /// * `transport` - The transport implementation
533    pub fn add_transport(&mut self, transport_type: TransportType, transport: Box<dyn Transport>) {
534        self.transports.insert(transport_type, Arc::from(transport));
535    }
536
537    /// Sends a message through the best available transport, with fallback.
538    ///
539    /// DORS selects a primary transport (applying hysteresis, cooldown, and
540    /// stability checks). If the primary's `send()` fails synchronously, the
541    /// remaining transports are tried in descending score order before
542    /// returning an error. Each synchronous failure is recorded via
543    /// `record_retry_failure` so DORS can adjust future scoring.
544    ///
545    /// # Internet transport — fallback does NOT apply
546    ///
547    /// Internet `send()` enqueues the message and returns `Ok(())`
548    /// immediately. The actual wire-level outcome is reported asynchronously
549    /// via the `confirm_sent` / `report_send_failure` confirmation loop on
550    /// `InternetTransport`. Because `send()` never fails synchronously, the
551    /// fallback loop below will **not** trigger for Internet. If the
552    /// WebSocket is silently broken (status still `Available` but sends
553    /// fail), messages will be enqueued, detected as failures by the
554    /// confirmation-loop timeout, and counted in DORS metrics — but they
555    /// are **not** retried on an alternative transport by this method.
556    /// Retry/re-routing for those messages is handled by the higher-level
557    /// outbox retry mechanism.
558    pub fn send(&mut self, message: &Message) -> Result<()> {
559        // Stamp the negotiated wire codec for this recipient. The clone happens
560        // only on the binary path (peers known to support it); JSON and unknown
561        // peers reuse the borrowed message. The internet transport ignores the
562        // stamp (its relay bridge is JSON-only), and mesh transports only ever
563        // emit to a recipient that is a directly connected peer, so `recipient`
564        // is the physical peer whose capability was recorded — keying by it
565        // matches the same peer-string the MTU path already keys by.
566        //
567        // The codec travels on the message, and both `Transport::send` and this
568        // method take `&Message` as public API, so carrying the stamp across
569        // that boundary needs an owned message — hence this clone. Note it is a
570        // full-message clone: it copies `binary_content` (up to the max message
571        // size), not just the envelope, so for media-sized payloads sent to a
572        // binary-capable peer it is a real payload memcpy on the hot path. It is
573        // a deliberate tradeoff: threading the codec as a separate argument would
574        // shave the clone but is a breaking change to the public `Transport`
575        // trait every downstream implementor relies on. The transport's own
576        // queue-clone is unaffected either way. If profiling ever flags this,
577        // stamp via a lightweight wrapper rather than cloning the payload.
578        let stamped;
579        let message = if message.wire_codec() != WireCodec::BinaryV1
580            && self.peer_binary_wire.contains(message.recipient.as_str())
581        {
582            let mut m = message.clone();
583            m.set_wire_codec(WireCodec::BinaryV1);
584            stamped = m;
585            &stamped
586        } else {
587            message
588        };
589
590        let available = self.get_available_transports();
591        if available.is_empty() {
592            return Err(Error::Transport(TransportError::TransportNotAvailable(
593                "No available transport".to_string(),
594            )));
595        }
596
597        // Single timestamp shared by every RoutingDecision emitted from this
598        // send cycle — cheaper than per-emit `Utc::now()` and lets downstream
599        // consumers group decisions by timestamp.
600        let now_ms = Utc::now().timestamp_millis();
601
602        let previous = self.current_transport;
603
604        // DORS selects the primary transport (applies hysteresis/cooldown/stability).
605        let primary = self
606            .selector
607            .select_transport(message, &available)
608            .ok_or_else(|| {
609                Error::Transport(TransportError::TransportNotAvailable(
610                    "No suitable transport selected from available transports".to_string(),
611                ))
612            })?;
613
614        // Emit DORS observability: scores and selection (before send attempt).
615        // At the default (non-diagnostic) tier we project the detailed
616        // breakdown down to `(transport, total)` — a single scoring pass
617        // feeds both the legacy event and the routing record.
618        let detailed_scores: Option<Vec<(TransportType, TransportScore)>> =
619            if self.routing_diagnostic && self.routing_decision_callback.is_some() {
620                Some(self.selector.score_and_rank_detailed(message, &available))
621            } else {
622                None
623            };
624        let detailed_slice = detailed_scores.as_deref();
625        let scored: Vec<(TransportType, f32)> = match detailed_slice {
626            Some(d) => d.iter().map(|(t, s)| (*t, s.total)).collect(),
627            None => self.selector.score_and_rank(message, &available),
628        };
629        // Sanitize the fallback-demotion sentinel out of the emitted scores (the
630        // `scored` vec itself keeps the raw totals — the fallback loop below
631        // relies on Internet ranking last).
632        let scores: Vec<(String, f32)> = scored
633            .iter()
634            .map(|(t, s)| (t.to_string(), display_routing_score(*s)))
635            .collect();
636        self.emit_dors_event(Event::dors_score_updated(scores));
637
638        self.emit_routing_decision(|| {
639            self.build_routing_decision(
640                now_ms,
641                RoutingPhase::ScoreUpdated,
642                previous,
643                None,
644                None,
645                None,
646                detailed_slice,
647            )
648        });
649        // Telemetry-only; sanitize the fallback-demotion sentinel so the emitted
650        // selection score is the real 0–125 quality value, not the negative rank.
651        let primary_score = scored
652            .iter()
653            .find(|(t, _)| *t == primary)
654            .map(|(_, s)| display_routing_score(*s))
655            .unwrap_or(0.0);
656        let selection_reason = if previous.is_none() {
657            DorsReasonCode::InitialSelection
658        } else {
659            DorsReasonCode::PrimarySelected
660        };
661        self.emit_dors_event(Event::dors_transport_selected(
662            previous.map(|t| t.to_string()),
663            primary.to_string(),
664            selection_reason,
665            primary_score,
666        ));
667        self.emit_routing_decision(|| {
668            self.build_routing_decision(
669                now_ms,
670                RoutingPhase::Selected,
671                previous,
672                Some(primary),
673                Some(primary_score),
674                Some(Self::dors_reason_code_to_routing(selection_reason)),
675                detailed_slice,
676            )
677        });
678
679        // Try the primary transport first.
680        let primary_result = {
681            let transport = self
682                .transports
683                .get(&primary)
684                .ok_or_else(|| Error::Other(format!("Transport {:?} not found", primary)))?;
685            transport.send(message)
686        };
687
688        match primary_result {
689            Ok(()) => {
690                self.current_transport = Some(primary);
691                if previous != Some(primary) {
692                    let reason_code = if previous.is_some_and(|p| !available.contains_key(&p)) {
693                        DorsReasonCode::CurrentUnavailable
694                    } else {
695                        DorsReasonCode::PrimarySuccess
696                    };
697                    self.emit_dors_event(Event::dors_transport_switched(
698                        previous.map(|t| t.to_string()),
699                        primary.to_string(),
700                        reason_code,
701                        None,
702                    ));
703                    self.emit_routing_decision(|| {
704                        self.build_routing_decision(
705                            now_ms,
706                            RoutingPhase::Switched,
707                            previous,
708                            Some(primary),
709                            Some(primary_score),
710                            Some(Self::dors_reason_code_to_routing(reason_code)),
711                            detailed_slice,
712                        )
713                    });
714                }
715                return Ok(());
716            }
717            Err(e) => {
718                let is_peer_routing = matches!(e, TransportError::PeerNotReachable(_));
719                if is_peer_routing {
720                    debug!(
721                        transport = ?primary,
722                        error = %e,
723                        "Peer not reachable via primary transport, trying fallback"
724                    );
725                } else {
726                    warn!(
727                        transport = ?primary,
728                        error = %e,
729                        "Primary transport send failed, trying fallback"
730                    );
731                    self.selector.record_retry_failure(primary);
732                }
733            }
734        }
735
736        // Primary failed — try remaining transports in score order.
737        // Reuse the scored ranking computed above; record_retry_failure only
738        // mutates retry_counts which score_and_rank does not read.
739        let mut last_error = None;
740        let mut attempted: Vec<TransportType> = vec![primary];
741
742        for (transport_type, _score) in &scored {
743            if *transport_type == primary {
744                continue;
745            }
746
747            // Emit escalation trigger at DORS boundary (typed reason), deduped.
748            if primary == TransportType::BLE && *transport_type == TransportType::WiFiDirect {
749                if let Some(trigger_reason) = self.selector.escalation_trigger_reason() {
750                    let reason_code = Self::escalation_trigger_reason_to_code(trigger_reason);
751                    self.emit_escalation_trigger_if_deduped(
752                        now_ms,
753                        reason_code,
754                        primary,
755                        *transport_type,
756                        None,
757                        detailed_slice,
758                    );
759                }
760            }
761
762            let transport = match self.transports.get(transport_type) {
763                Some(t) => t,
764                None => continue,
765            };
766
767            attempted.push(*transport_type);
768
769            match transport.send(message) {
770                Ok(()) => {
771                    self.current_transport = Some(*transport_type);
772                    self.selector.set_current_transport(*transport_type);
773                    // Emit switch only when active transport actually changed (fallback success).
774                    if previous != Some(*transport_type) {
775                        let reason_code = if primary == TransportType::BLE
776                            && *transport_type == TransportType::WiFiDirect
777                        {
778                            DorsReasonCode::EscalationApplied
779                        } else {
780                            DorsReasonCode::FallbackSuccess
781                        };
782                        self.emit_dors_event(Event::dors_transport_switched(
783                            previous.map(|t| t.to_string()),
784                            transport_type.to_string(),
785                            reason_code,
786                            Some("primary send failed, fallback succeeded".to_string()),
787                        ));
788                        let fallback = *transport_type;
789                        self.emit_routing_decision(|| {
790                            self.build_routing_decision(
791                                now_ms,
792                                RoutingPhase::Switched,
793                                previous,
794                                Some(fallback),
795                                None,
796                                Some(Self::dors_reason_code_to_routing(reason_code)),
797                                detailed_slice,
798                            )
799                        });
800                    }
801                    // Escalation applied only when BLE→WiFi fallback actually succeeded.
802                    if primary == TransportType::BLE && *transport_type == TransportType::WiFiDirect
803                    {
804                        self.emit_dors_event(Event::dors_escalation_triggered(
805                            DorsEscalationPhase::Applied,
806                            primary.to_string(),
807                            transport_type.to_string(),
808                            DorsEscalationReasonCode::FallbackSuccess,
809                            Some("primary BLE send failed, fallback to WiFi succeeded".to_string()),
810                        ));
811                        let escalated = *transport_type;
812                        self.emit_routing_decision(|| {
813                            self.build_routing_decision(
814                                now_ms,
815                                RoutingPhase::Escalated,
816                                Some(primary),
817                                Some(escalated),
818                                None,
819                                Some(Self::escalation_reason_code_to_routing(
820                                    DorsEscalationReasonCode::FallbackSuccess,
821                                )),
822                                detailed_slice,
823                            )
824                        });
825                    }
826                    return Ok(());
827                }
828                Err(e) => {
829                    if !matches!(e, TransportError::PeerNotReachable(_)) {
830                        warn!(
831                            transport = ?transport_type,
832                            error = %e,
833                            "Fallback transport send failed, trying next"
834                        );
835                        self.selector.record_retry_failure(*transport_type);
836                    }
837                    last_error = Some(e);
838                }
839            }
840        }
841
842        let terminal_error = last_error.unwrap_or_else(|| {
843            TransportError::SendFailed(format!("All transports failed (tried {:?})", attempted))
844        });
845        Err(Error::Transport(terminal_error))
846    }
847
848    /// Sends a message via a specific transport, bypassing DORS selection.
849    ///
850    /// # Arguments
851    ///
852    /// * `message` - The message to send
853    /// * `transport_type` - The transport to use
854    ///
855    /// # Returns
856    ///
857    /// Returns Ok(()) if sent successfully, Err otherwise.
858    pub fn send_via_transport(
859        &mut self,
860        message: &Message,
861        transport_type: TransportType,
862    ) -> Result<()> {
863        let transport = self
864            .transports
865            .get(&transport_type)
866            .ok_or_else(|| Error::Other(format!("Transport {:?} not found", transport_type)))?;
867
868        if transport.status() != TransportStatus::Available {
869            return Err(Error::Other(format!(
870                "Transport {:?} is not available",
871                transport_type
872            )));
873        }
874
875        // Preserved as `Error::Transport`, not flattened into `Error::Other`.
876        // Flattening discarded the variant every classifier keys on —
877        // `SessionStateError::classify` and `Protocol::is_no_carrier_error`
878        // both answer on `Error::Transport(_)`, so on this path they saw
879        // `Unknown`/`false` and the rendered string was the only surviving
880        // signal. That is exactly the string that must stop being read.
881        transport.send(message).map_err(Error::Transport)?;
882
883        // Only update current transport after successful send
884        self.current_transport = Some(transport_type);
885
886        Ok(())
887    }
888
889    /// Lists every peer reachable over a direct mesh link, with the transport
890    /// that link runs on.
891    ///
892    /// Only the peer-to-peer carriers are consulted: a mesh neighbor is one we
893    /// can hand a frame to over our own radio, which is what makes it a
894    /// candidate next hop. Infrastructure carriers (Internet, Nostr, Reticulum)
895    /// reach peers through something else, so they report no links and appear
896    /// here as nothing — a message routed through them is not taking a mesh
897    /// hop, it is leaving the mesh.
898    ///
899    /// A peer visible on more than one carrier is reported once, on the first
900    /// carrier that claims it; [`Self::send_to_neighbor`] resolves the same way,
901    /// so the pair stay consistent.
902    pub fn mesh_neighbors(&self) -> Vec<MeshNeighbor> {
903        let mut neighbors: Vec<MeshNeighbor> = Vec::new();
904
905        for transport_type in MESH_TRANSPORTS {
906            let Some(transport) = self.transports.get(transport_type) else {
907                continue;
908            };
909            for link in transport.connected_peers() {
910                if neighbors.iter().any(|n| n.peer_id == link.peer_id) {
911                    continue;
912                }
913                neighbors.push(MeshNeighbor {
914                    peer_id: link.peer_id,
915                    rssi: link.rssi,
916                    transport: *transport_type,
917                });
918            }
919        }
920
921        neighbors
922    }
923
924    /// Whether a frame for `peer_id` can plausibly arrive without being carried
925    /// by other devices.
926    ///
927    /// This exists because **a send returning `Ok` is not evidence of
928    /// reachability**. Only BLE refuses a recipient it holds no link to; Wi-Fi
929    /// Direct and Reticulum enqueue for any recipient and report success, so a
930    /// frame handed to them for someone out of range is queued for a link that
931    /// will never drain — reported as sent, silently swallowed. Anything that
932    /// decides "the mesh has to carry this" from a send failure therefore never
933    /// fires on a device where one of those carriers is up. Asking this instead
934    /// keeps that decision on facts we can check.
935    ///
936    /// Two ways the answer is yes:
937    ///
938    /// - An **infrastructure** carrier is available. Internet, Nostr and
939    ///   Reticulum do their own routing and reach peers we hold no radio link
940    ///   to, so a direct send is a real delivery attempt. (Reticulum counts
941    ///   here because it is a routing network in its own right — its `Ok` means
942    ///   "accepted for routing", which is as much as any relay-style carrier
943    ///   promises.)
944    /// - The peer is one of our own **mesh neighbors**, so a mesh carrier can
945    ///   hand the frame straight over.
946    ///
947    /// Checked in that order so the common online case costs a status scan and
948    /// never enumerates links.
949    pub fn can_reach_without_carrying(&self, peer_id: &str) -> bool {
950        let has_infrastructure = self.transports.iter().any(|(transport_type, transport)| {
951            !MESH_TRANSPORTS.contains(transport_type)
952                && transport.status() == TransportStatus::Available
953        });
954        if has_infrastructure {
955            return true;
956        }
957
958        MESH_TRANSPORTS.iter().any(|transport_type| {
959            self.transports
960                .get(transport_type)
961                .filter(|transport| transport.status() == TransportStatus::Available)
962                .is_some_and(|transport| {
963                    transport
964                        .connected_peers()
965                        .iter()
966                        .any(|link| link.peer_id == peer_id)
967                })
968        })
969    }
970
971    /// Whether `transport_type` can actually put a frame in front of `peer_id`.
972    ///
973    /// The narrower question [`Self::can_reach_without_carrying`] answers across
974    /// every carrier, asked of one. It exists for the same reason: a transport
975    /// returning `Ok` is not evidence the frame can arrive. A **mesh** carrier
976    /// can only address a peer it holds a live link to, and Wi-Fi Direct
977    /// enqueues for any recipient regardless — so a frame handed to it for
978    /// someone several hops away is queued for a link that never drains.
979    /// **Infrastructure** carriers do their own routing, so for them the answer
980    /// is yes whenever they are available.
981    ///
982    /// Callers that would otherwise trust a preferred transport — replying on
983    /// the link a message arrived over, above all — must ask this first, or the
984    /// reply dies on exactly the multi-hop path that made forwarding necessary.
985    pub fn can_address_via(&self, transport_type: TransportType, peer_id: &str) -> bool {
986        let Some(transport) = self.transports.get(&transport_type) else {
987            return false;
988        };
989        if transport.status() != TransportStatus::Available {
990            return false;
991        }
992        if !MESH_TRANSPORTS.contains(&transport_type) {
993            return true;
994        }
995        transport
996            .connected_peers()
997            .iter()
998            .any(|link| link.peer_id == peer_id)
999    }
1000
1001    /// Hands `message` to a specific mesh neighbor, whatever the message's own
1002    /// recipient is.
1003    ///
1004    /// This is the forwarding primitive: the frame crosses one link, to the
1005    /// peer named by `peer_id`. Because that peer is the physical target, the
1006    /// binary wire codec is negotiated against **its** capabilities rather than
1007    /// the recipient's — stamping a forwarded frame binary because the distant
1008    /// recipient supports it could put bytes a neighbor cannot decode onto that
1009    /// neighbor's link.
1010    ///
1011    /// Returns the transport the frame went out on.
1012    pub fn send_to_neighbor(&self, peer_id: &str, message: &Message) -> Result<TransportType> {
1013        let stamped;
1014        let message = if message.wire_codec() != WireCodec::BinaryV1
1015            && self.peer_binary_wire.contains(peer_id)
1016        {
1017            let mut m = message.clone();
1018            m.set_wire_codec(WireCodec::BinaryV1);
1019            stamped = m;
1020            &stamped
1021        } else if message.wire_codec() == WireCodec::BinaryV1
1022            && !self.peer_binary_wire.contains(peer_id)
1023        {
1024            // The frame arrived stamped binary (or was stamped for a different
1025            // peer) but this hop cannot decode it. Fall back to the JSON floor
1026            // rather than writing bytes the neighbor will drop.
1027            let mut m = message.clone();
1028            m.set_wire_codec(WireCodec::Json);
1029            stamped = m;
1030            &stamped
1031        } else {
1032            message
1033        };
1034
1035        let mut last_error: Option<String> = None;
1036
1037        for transport_type in MESH_TRANSPORTS {
1038            let Some(transport) = self.transports.get(transport_type) else {
1039                continue;
1040            };
1041            if transport.status() != TransportStatus::Available {
1042                continue;
1043            }
1044            match transport.send_to_peer(peer_id, message) {
1045                Ok(()) => return Ok(*transport_type),
1046                Err(e) => last_error = Some(e.to_string()),
1047            }
1048        }
1049
1050        Err(Error::Transport(TransportError::PeerNotReachable(format!(
1051            "no mesh transport holds a link to {}{}",
1052            peer_id,
1053            last_error.map(|e| format!(" ({})", e)).unwrap_or_default()
1054        ))))
1055    }
1056
1057    /// Attempts to receive a message from any transport.
1058    ///
1059    /// # Returns
1060    ///
1061    /// Returns Ok(Some((TransportType, Message))) if a message was received, Ok(None) if no message available.
1062    pub fn receive(&self) -> Result<Option<(TransportType, Message)>> {
1063        // Check all transports for messages
1064        for (transport_type, transport) in &self.transports {
1065            match transport.receive() {
1066                Ok(Some(message)) => {
1067                    debug!(
1068                        transport = ?transport_type,
1069                        sender = %message.sender,
1070                        recipient = %message.recipient,
1071                        "TransportManager found message"
1072                    );
1073                    return Ok(Some((*transport_type, message)));
1074                }
1075                Ok(None) => {
1076                    // No message from this transport, continue checking others
1077                }
1078                Err(e) => {
1079                    warn!(
1080                        transport = ?transport_type,
1081                        error = %e,
1082                        "Transport receive error"
1083                    );
1084                }
1085            }
1086        }
1087        Ok(None)
1088    }
1089
1090    /// Gets metrics for all available transports.
1091    ///
1092    /// # Returns
1093    ///
1094    /// Returns a map of transport type to metrics.
1095    pub fn get_available_transports(&self) -> HashMap<TransportType, TransportMetrics> {
1096        let mut available = HashMap::new();
1097
1098        for (transport_type, transport) in &self.transports {
1099            let maybe_metrics = {
1100                let status = transport.status();
1101                if status == TransportStatus::Available {
1102                    Some(transport.metrics())
1103                } else {
1104                    debug!(
1105                        transport = ?transport_type,
1106                        status = ?status,
1107                        "Transport excluded from available set"
1108                    );
1109                    None
1110                }
1111            };
1112
1113            if let Some(mut metrics) = maybe_metrics {
1114                if let Some(stats) = self.observations.get(transport_type) {
1115                    stats.apply_to_metrics(&mut metrics);
1116                }
1117                available.insert(*transport_type, metrics);
1118            }
1119        }
1120
1121        available
1122    }
1123
1124    /// Gets the current active transport type.
1125    pub fn current_transport(&self) -> Option<TransportType> {
1126        self.current_transport
1127    }
1128
1129    /// Returns the current status of every registered transport, including
1130    /// those not currently [`TransportStatus::Available`].
1131    ///
1132    /// Used by the telemetry aggregator to detect state transitions; unlike
1133    /// [`TransportManager::get_available_transports`], this only reads each
1134    /// transport's status with no metrics work.
1135    pub fn get_all_transport_statuses(&self) -> HashMap<TransportType, TransportStatus> {
1136        let mut out = HashMap::with_capacity(self.transports.len());
1137        for (transport_type, transport) in &self.transports {
1138            out.insert(*transport_type, transport.status());
1139        }
1140        out
1141    }
1142
1143    /// Returns the per-transport status map AND the available-only metrics
1144    /// map in a single pass.
1145    ///
1146    /// Equivalent to calling [`Self::get_all_transport_statuses`] and
1147    /// [`Self::get_available_transports`] back-to-back, but reads each
1148    /// transport's status once instead of twice. Used by the per-tick
1149    /// telemetry aggregator.
1150    pub fn snapshot_status_and_available(
1151        &self,
1152    ) -> (
1153        HashMap<TransportType, TransportStatus>,
1154        HashMap<TransportType, TransportMetrics>,
1155    ) {
1156        let mut statuses = HashMap::with_capacity(self.transports.len());
1157        let mut available = HashMap::new();
1158
1159        for (transport_type, transport) in &self.transports {
1160            let status = transport.status();
1161            let metrics = if status == TransportStatus::Available {
1162                Some(transport.metrics())
1163            } else {
1164                None
1165            };
1166            statuses.insert(*transport_type, status);
1167            if let Some(mut metrics) = metrics {
1168                if let Some(stats) = self.observations.get(transport_type) {
1169                    stats.apply_to_metrics(&mut metrics);
1170                }
1171                available.insert(*transport_type, metrics);
1172            }
1173        }
1174
1175        (statuses, available)
1176    }
1177
1178    /// Starts all transports.
1179    pub fn start(&mut self) -> Result<()> {
1180        for (transport_type, transport) in &self.transports {
1181            transport.start().map_err(|e| {
1182                Error::Other(format!(
1183                    "Failed to start transport {:?}: {}",
1184                    transport_type, e
1185                ))
1186            })?;
1187        }
1188        Ok(())
1189    }
1190
1191    /// Stops all transports.
1192    pub fn stop(&mut self) -> Result<()> {
1193        for (transport_type, transport) in &self.transports {
1194            transport.stop().map_err(|e| {
1195                Error::Other(format!(
1196                    "Failed to stop transport {:?}: {}",
1197                    transport_type, e
1198                ))
1199            })?;
1200        }
1201        Ok(())
1202    }
1203
1204    /// Gets a shared handle to a specific transport.
1205    pub fn get_transport(&self, transport_type: TransportType) -> Option<Arc<dyn Transport>> {
1206        self.transports.get(&transport_type).cloned()
1207    }
1208
1209    /// Removes a transport from the manager.
1210    ///
1211    /// # Arguments
1212    ///
1213    /// * `transport_type` - Type of transport to remove
1214    pub fn remove_transport(&mut self, transport_type: TransportType) {
1215        self.transports.remove(&transport_type);
1216
1217        // Clear current transport if it was the one removed
1218        if self.current_transport == Some(transport_type) {
1219            self.current_transport = None;
1220        }
1221
1222        self.observations.remove(&transport_type);
1223    }
1224
1225    /// Gets a list of all active transport types.
1226    ///
1227    /// # Returns
1228    ///
1229    /// Returns a vector of transport types that are currently added to the manager.
1230    pub fn get_active_transports(&self) -> Vec<TransportType> {
1231        self.transports.keys().copied().collect()
1232    }
1233
1234    /// Checks if DORS suggests escalating from BLE to WiFi Direct.
1235    pub fn should_escalate_to_wifi(&self) -> bool {
1236        self.selector.should_escalate_to_wifi()
1237    }
1238
1239    /// Records a retry failure for the given transport.
1240    pub fn record_retry_failure(&mut self, transport_type: TransportType) {
1241        self.selector.record_retry_failure(transport_type);
1242    }
1243
1244    /// Resets retry count for the given transport after successful delivery.
1245    pub fn reset_retry_count(&mut self, transport_type: TransportType) {
1246        self.selector.reset_retry_count(transport_type);
1247    }
1248
1249    /// Records a successful end-to-end delivery for the given transport.
1250    pub fn record_delivery_success(
1251        &mut self,
1252        transport_type: TransportType,
1253        latency_ms: u32,
1254        hop_count: u8,
1255    ) {
1256        let stats = self.observations.entry(transport_type).or_default();
1257        stats.record_success(latency_ms, hop_count);
1258    }
1259
1260    /// Records a delivery failure (after exhausting retries) for the given transport.
1261    pub fn record_delivery_failure(&mut self, transport_type: TransportType) {
1262        let stats = self.observations.entry(transport_type).or_default();
1263        stats.record_failure();
1264    }
1265
1266    /// Updates the DORS selector configuration at runtime, preserving
1267    /// accumulated state (transport history, retry counts, signal tracking).
1268    pub fn update_selector_config(&mut self, config: DorsConfig) {
1269        self.selector.update_config(config);
1270    }
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276    use offline_protocol_core::{AppId, UserId};
1277    use offline_protocol_router::DorsConfig;
1278    use offline_protocol_transport::{mock::MockTransport, TransportType};
1279    use std::sync::Mutex;
1280
1281    fn create_test_message() -> Message {
1282        Message::new(
1283            UserId::new("alice").unwrap(),
1284            UserId::new("bob").unwrap(),
1285            AppId::new("test").unwrap(),
1286            "Test message",
1287        )
1288    }
1289
1290    #[test]
1291    fn test_transport_manager_creation() {
1292        let selector = TransportSelector::with_config(DorsConfig::default());
1293        let manager = TransportManager::new(selector);
1294        assert_eq!(manager.transports.len(), 0);
1295        assert!(manager.current_transport().is_none());
1296    }
1297
1298    #[test]
1299    fn test_add_transport() {
1300        let selector = TransportSelector::with_config(DorsConfig::default());
1301        let mut manager = TransportManager::new(selector);
1302
1303        let transport = Box::new(MockTransport::new(TransportType::BLE));
1304        manager.add_transport(TransportType::BLE, transport);
1305
1306        assert_eq!(manager.transports.len(), 1);
1307    }
1308
1309    #[test]
1310    fn test_start_stop_through_shared_transport_handle() {
1311        let selector = TransportSelector::with_config(DorsConfig::default());
1312        let mut manager = TransportManager::new(selector);
1313        manager.add_transport(
1314            TransportType::BLE,
1315            Box::new(MockTransport::new(TransportType::BLE)),
1316        );
1317
1318        // The handle shares the same transport the manager drives: start()
1319        // and stop() go through &self, no per-transport mutex involved.
1320        let handle = manager
1321            .get_transport(TransportType::BLE)
1322            .expect("transport registered");
1323        assert_eq!(handle.status(), TransportStatus::Unavailable);
1324
1325        manager.start().unwrap();
1326        assert_eq!(handle.status(), TransportStatus::Available);
1327
1328        manager.stop().unwrap();
1329        assert_eq!(handle.status(), TransportStatus::Disconnected);
1330    }
1331
1332    #[test]
1333    fn test_send_message() {
1334        let selector = TransportSelector::with_config(DorsConfig::default());
1335        let mut manager = TransportManager::new(selector);
1336
1337        let transport = MockTransport::new(TransportType::BLE);
1338        transport.start().unwrap();
1339        manager.add_transport(TransportType::BLE, Box::new(transport));
1340
1341        let message = create_test_message();
1342        let result = manager.send(&message);
1343        assert!(result.is_ok());
1344    }
1345
1346    #[test]
1347    fn send_stamps_binary_only_for_capable_peer() {
1348        let selector = TransportSelector::with_config(DorsConfig::default());
1349        let mut manager = TransportManager::new(selector);
1350        let transport = MockTransport::new(TransportType::BLE);
1351        transport.start().unwrap();
1352        manager.add_transport(TransportType::BLE, Box::new(transport));
1353
1354        // Unknown peer -> JSON (the default).
1355        manager.send(&create_test_message()).unwrap();
1356        // Peer recorded as binary-capable -> the send is stamped binary.
1357        manager.mark_peer_binary_wire("bob", true);
1358        manager.send(&create_test_message()).unwrap();
1359
1360        let mock = manager
1361            .transports
1362            .get(&TransportType::BLE)
1363            .unwrap()
1364            .as_any()
1365            .downcast_ref::<MockTransport>()
1366            .unwrap();
1367        let sent = mock.sent_messages();
1368        assert_eq!(sent.len(), 2);
1369        assert_eq!(sent[0].wire_codec(), WireCodec::Json);
1370        assert_eq!(sent[1].wire_codec(), WireCodec::BinaryV1);
1371    }
1372
1373    #[test]
1374    fn send_reverts_to_json_after_peer_downgrades() {
1375        let selector = TransportSelector::with_config(DorsConfig::default());
1376        let mut manager = TransportManager::new(selector);
1377        let transport = MockTransport::new(TransportType::BLE);
1378        transport.start().unwrap();
1379        manager.add_transport(TransportType::BLE, Box::new(transport));
1380
1381        // Learn the peer is binary-capable -> the next send is stamped binary.
1382        manager.mark_peer_binary_wire("bob", true);
1383        assert!(manager.peer_supports_binary_wire("bob"));
1384        manager.send(&create_test_message()).unwrap();
1385
1386        // A later key package that no longer advertises support downgrades the
1387        // peer (e.g. after a session reset, or the codec being turned off on
1388        // their side); subsequent sends must fall back to JSON, not keep
1389        // emitting binary a legacy build could not decode.
1390        manager.mark_peer_binary_wire("bob", false);
1391        assert!(!manager.peer_supports_binary_wire("bob"));
1392        manager.send(&create_test_message()).unwrap();
1393
1394        let mock = manager
1395            .transports
1396            .get(&TransportType::BLE)
1397            .unwrap()
1398            .as_any()
1399            .downcast_ref::<MockTransport>()
1400            .unwrap();
1401        let sent = mock.sent_messages();
1402        assert_eq!(sent.len(), 2);
1403        assert_eq!(sent[0].wire_codec(), WireCodec::BinaryV1);
1404        assert_eq!(sent[1].wire_codec(), WireCodec::Json);
1405    }
1406
1407    #[test]
1408    fn test_dors_events_emitted_when_callback_set() {
1409        let selector = TransportSelector::with_config(DorsConfig::default());
1410        let mut manager = TransportManager::new(selector);
1411
1412        let transport = MockTransport::new(TransportType::BLE);
1413        transport.start().unwrap();
1414        manager.add_transport(TransportType::BLE, Box::new(transport));
1415
1416        let events: std::sync::Arc<Mutex<Vec<Event>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1417        let events_clone = events.clone();
1418        manager.set_dors_event_callback(Some(Arc::new(move |e| {
1419            events_clone.lock().unwrap().push(e);
1420        })));
1421
1422        let message = create_test_message();
1423        let _ = manager.send(&message);
1424
1425        let captured = events.lock().unwrap();
1426        assert!(
1427            captured
1428                .iter()
1429                .any(|e| matches!(e, crate::events::Event::DorsScoreUpdated { .. })),
1430            "expected dors_score_updated"
1431        );
1432        assert!(
1433            captured
1434                .iter()
1435                .any(|e| { matches!(e, crate::events::Event::DorsTransportSelected { .. }) }),
1436            "expected dors_transport_selected"
1437        );
1438    }
1439
1440    #[test]
1441    fn test_receive_message() {
1442        let selector = TransportSelector::with_config(DorsConfig::default());
1443        let mut manager = TransportManager::new(selector);
1444
1445        let transport = MockTransport::new(TransportType::BLE);
1446        transport.start().unwrap();
1447        let message = create_test_message();
1448        transport.queue_message(message.clone());
1449
1450        manager.add_transport(TransportType::BLE, Box::new(transport));
1451
1452        let received = manager.receive().unwrap();
1453        assert!(matches!(received, Some((TransportType::BLE, _))));
1454    }
1455
1456    #[test]
1457    fn test_record_delivery_success_enriches_metrics() {
1458        let selector = TransportSelector::with_config(DorsConfig::default());
1459        let mut manager = TransportManager::new(selector);
1460
1461        let transport = MockTransport::new(TransportType::BLE);
1462        transport.start().unwrap();
1463        manager.add_transport(TransportType::BLE, Box::new(transport));
1464
1465        manager.record_delivery_success(TransportType::BLE, 150, 2);
1466
1467        let metrics = manager.get_available_transports();
1468        let ble_metrics = metrics.get(&TransportType::BLE).expect("metrics");
1469
1470        assert_eq!(ble_metrics.success_count, 1);
1471        assert_eq!(ble_metrics.failure_count, 0);
1472        assert!(ble_metrics.delivery_ratio.expect("delivery ratio") > 0.99);
1473        assert_eq!(ble_metrics.average_hop_count, Some(2.0));
1474        assert_eq!(ble_metrics.latency_ms, Some(150));
1475    }
1476
1477    #[test]
1478    fn test_record_delivery_failure_enriches_metrics() {
1479        let selector = TransportSelector::with_config(DorsConfig::default());
1480        let mut manager = TransportManager::new(selector);
1481
1482        let transport = MockTransport::new(TransportType::BLE);
1483        transport.start().unwrap();
1484        manager.add_transport(TransportType::BLE, Box::new(transport));
1485
1486        manager.record_delivery_failure(TransportType::BLE);
1487        let metrics = manager.get_available_transports();
1488        let ble_metrics = metrics.get(&TransportType::BLE).expect("metrics");
1489
1490        assert_eq!(ble_metrics.success_count, 0);
1491        assert_eq!(ble_metrics.failure_count, 1);
1492        assert!(ble_metrics.drop_rate.expect("drop ratio") > 0.99);
1493    }
1494}