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