Skip to main content

hive_btle/
document.rs

1// Copyright (c) 2025-2026 (r)evolve - Revolve Team LLC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! HIVE Document wire format for BLE mesh sync
17//!
18//! This module provides the unified document format used across all platforms
19//! (iOS, Android, ESP32) for mesh synchronization. The format is designed for
20//! efficient BLE transmission while supporting CRDT semantics.
21//!
22//! ## Wire Format
23//!
24//! ```text
25//! Header (8 bytes):
26//!   version:  4 bytes (LE) - document version number
27//!   node_id:  4 bytes (LE) - source node identifier
28//!
29//! GCounter (4 + N*12 bytes):
30//!   num_entries: 4 bytes (LE)
31//!   entries[N]:
32//!     node_id: 4 bytes (LE)
33//!     count:   8 bytes (LE)
34//!
35//! Extended Section (optional, when peripheral data present):
36//!   marker:         1 byte (0xAB)
37//!   reserved:       1 byte
38//!   peripheral_len: 2 bytes (LE)
39//!   peripheral:     variable (34-43 bytes)
40//! ```
41
42#[cfg(not(feature = "std"))]
43use alloc::vec::Vec;
44
45#[cfg(feature = "legacy-chat")]
46use crate::sync::crdt::ChatCRDT;
47use crate::sync::crdt::{EmergencyEvent, EventType, GCounter, Peripheral, PeripheralEvent};
48use crate::NodeId;
49
50/// Marker byte indicating extended section with peripheral data
51pub const EXTENDED_MARKER: u8 = 0xAB;
52
53/// Marker byte indicating emergency event section
54pub const EMERGENCY_MARKER: u8 = 0xAC;
55
56/// Marker byte indicating chat CRDT section
57///
58/// Used to include persisted chat messages in the document for CRDT sync.
59///
60/// ```text
61/// marker:   1 byte (0xAD)
62/// reserved: 1 byte (0x00)
63/// len:      2 bytes (LE) - length of chat CRDT data
64/// chat:     variable - ChatCRDT encoded data
65/// ```
66pub const CHAT_MARKER: u8 = 0xAD;
67
68/// Marker byte indicating encrypted document (mesh-wide)
69///
70/// When present, the entire document payload following the marker is encrypted
71/// using ChaCha20-Poly1305. The marker format is:
72///
73/// ```text
74/// marker:   1 byte (0xAE)
75/// reserved: 1 byte (0x00)
76/// payload:  12 bytes nonce + variable ciphertext (includes 16-byte auth tag)
77/// ```
78///
79/// Encryption happens at the HiveMesh layer before transmission, and decryption
80/// happens upon receipt before document parsing.
81pub const ENCRYPTED_MARKER: u8 = 0xAE;
82
83/// Marker byte indicating per-peer E2EE message
84///
85/// Used for end-to-end encrypted messages between specific peer pairs.
86/// Only the sender and recipient (who share a session key) can decrypt.
87///
88/// ```text
89/// marker:     1 byte (0xAF)
90/// flags:      1 byte (bit 0: key_exchange, bit 1: forward_secrecy)
91/// recipient:  4 bytes (LE) - recipient node ID
92/// sender:     4 bytes (LE) - sender node ID
93/// counter:    8 bytes (LE) - message counter for replay protection
94/// nonce:      12 bytes
95/// ciphertext: variable (includes 16-byte auth tag)
96/// ```
97pub const PEER_E2EE_MARKER: u8 = 0xAF;
98
99/// Marker byte indicating key exchange message for per-peer E2EE
100///
101/// Used to establish E2EE sessions between peers via X25519 key exchange.
102///
103/// ```text
104/// marker:     1 byte (0xB0)
105/// sender:     4 bytes (LE) - sender node ID
106/// flags:      1 byte (bit 0: is_ephemeral)
107/// public_key: 32 bytes - X25519 public key
108/// ```
109pub const KEY_EXCHANGE_MARKER: u8 = 0xB0;
110
111/// Marker byte indicating relay envelope for multi-hop transmission
112///
113/// Used to wrap documents for multi-hop relay with deduplication and TTL.
114/// See [`crate::relay`] module for details.
115///
116/// ```text
117/// marker:        1 byte (0xB1)
118/// flags:         1 byte (bit 0: requires_ack, bit 1: is_broadcast)
119/// message_id:    16 bytes (UUID for deduplication)
120/// hop_count:     1 byte (current hop count)
121/// max_hops:      1 byte (TTL)
122/// origin_node:   4 bytes (LE) - original sender node ID
123/// payload_len:   4 bytes (LE)
124/// payload:       variable (encrypted document)
125/// ```
126pub const RELAY_ENVELOPE_MARKER: u8 = 0xB1;
127
128/// Marker byte indicating delta document for bandwidth-efficient sync
129///
130/// Used to send only changed operations instead of full state snapshots.
131/// See [`crate::sync::delta_document`] module for details.
132///
133/// ```text
134/// marker:        1 byte (0xB2)
135/// flags:         1 byte (bit 0: has_vector_clock, bit 1: is_response)
136/// origin_node:   4 bytes (LE) - origin node ID
137/// timestamp_ms:  8 bytes (LE) - creation timestamp
138/// vector_clock:  variable (if flag set)
139/// op_count:      2 bytes (LE) - number of operations
140/// operations:    variable
141/// ```
142pub const DELTA_DOCUMENT_MARKER: u8 = 0xB2;
143
144/// Minimum document size (header only, no counter entries)
145pub const MIN_DOCUMENT_SIZE: usize = 8;
146
147/// Maximum recommended mesh size for reliable single-packet sync
148///
149/// Beyond this, documents may exceed typical BLE MTU (244 bytes).
150/// Size calculation: 8 (header) + 4 + (N × 12) (GCounter) + 42 (Peripheral)
151///   20 nodes = 8 + 244 + 42 = 294 bytes
152pub const MAX_MESH_SIZE: usize = 20;
153
154/// Target document size for single-packet transmission
155///
156/// Based on typical negotiated BLE MTU (247 bytes - 3 ATT overhead).
157pub const TARGET_DOCUMENT_SIZE: usize = 244;
158
159/// Hard limit before fragmentation would be required
160///
161/// BLE 5.0+ supports up to 517 byte MTU, but 512 is practical max payload.
162pub const MAX_DOCUMENT_SIZE: usize = 512;
163
164/// A HIVE document for mesh synchronization
165///
166/// Contains header information, a CRDT G-Counter for tracking mesh activity,
167/// optional peripheral data for events, optional emergency event with ACK tracking,
168/// and optional chat CRDT for mesh-wide messaging.
169#[derive(Debug, Clone)]
170pub struct HiveDocument {
171    /// Document version (incremented on each change)
172    pub version: u32,
173
174    /// Source node ID that created/last modified this document
175    pub node_id: NodeId,
176
177    /// CRDT G-Counter tracking activity across the mesh
178    pub counter: GCounter,
179
180    /// Optional peripheral data (sensor info, events)
181    pub peripheral: Option<Peripheral>,
182
183    /// Optional active emergency event with distributed ACK tracking
184    pub emergency: Option<EmergencyEvent>,
185
186    /// Optional chat CRDT for mesh-wide messaging (requires `legacy-chat` feature)
187    ///
188    /// Contains persisted chat messages that sync across the mesh using
189    /// add-only set semantics. Messages are identified by (origin_node, timestamp)
190    /// and automatically deduplicated during merge.
191    #[cfg(feature = "legacy-chat")]
192    pub chat: Option<ChatCRDT>,
193}
194
195impl Default for HiveDocument {
196    fn default() -> Self {
197        Self {
198            version: 1,
199            node_id: NodeId::default(),
200            counter: GCounter::new(),
201            peripheral: None,
202            emergency: None,
203            #[cfg(feature = "legacy-chat")]
204            chat: None,
205        }
206    }
207}
208
209impl HiveDocument {
210    /// Create a new document for the given node
211    pub fn new(node_id: NodeId) -> Self {
212        Self {
213            version: 1,
214            node_id,
215            counter: GCounter::new(),
216            peripheral: None,
217            emergency: None,
218            #[cfg(feature = "legacy-chat")]
219            chat: None,
220        }
221    }
222
223    /// Create with an initial peripheral
224    pub fn with_peripheral(mut self, peripheral: Peripheral) -> Self {
225        self.peripheral = Some(peripheral);
226        self
227    }
228
229    /// Create with an initial emergency event
230    pub fn with_emergency(mut self, emergency: EmergencyEvent) -> Self {
231        self.emergency = Some(emergency);
232        self
233    }
234
235    /// Create with an initial chat CRDT (requires `legacy-chat` feature)
236    #[cfg(feature = "legacy-chat")]
237    pub fn with_chat(mut self, chat: ChatCRDT) -> Self {
238        self.chat = Some(chat);
239        self
240    }
241
242    /// Increment the document version
243    pub fn increment_version(&mut self) {
244        self.version = self.version.wrapping_add(1);
245    }
246
247    /// Increment the counter for this node
248    pub fn increment_counter(&mut self) {
249        self.counter.increment(&self.node_id, 1);
250        self.increment_version();
251    }
252
253    /// Set an event on the peripheral
254    pub fn set_event(&mut self, event_type: EventType, timestamp: u64) {
255        if let Some(ref mut peripheral) = self.peripheral {
256            peripheral.set_event(event_type, timestamp);
257            self.increment_counter();
258        }
259    }
260
261    /// Clear the event from the peripheral
262    pub fn clear_event(&mut self) {
263        if let Some(ref mut peripheral) = self.peripheral {
264            peripheral.clear_event();
265            self.increment_version();
266        }
267    }
268
269    /// Set an emergency event
270    ///
271    /// Creates a new emergency event with the given source node, timestamp,
272    /// and list of known peers to track for ACKs.
273    pub fn set_emergency(&mut self, source_node: u32, timestamp: u64, known_peers: &[u32]) {
274        self.emergency = Some(EmergencyEvent::new(source_node, timestamp, known_peers));
275        self.increment_counter();
276    }
277
278    /// Record an ACK for the current emergency
279    ///
280    /// Returns true if the ACK was new (state changed)
281    pub fn ack_emergency(&mut self, node_id: u32) -> bool {
282        if let Some(ref mut emergency) = self.emergency {
283            if emergency.ack(node_id) {
284                self.increment_version();
285                return true;
286            }
287        }
288        false
289    }
290
291    /// Clear the emergency event
292    pub fn clear_emergency(&mut self) {
293        if self.emergency.is_some() {
294            self.emergency = None;
295            self.increment_version();
296        }
297    }
298
299    /// Get the current emergency event (if any)
300    pub fn get_emergency(&self) -> Option<&EmergencyEvent> {
301        self.emergency.as_ref()
302    }
303
304    /// Check if there's an active emergency
305    pub fn has_emergency(&self) -> bool {
306        self.emergency.is_some()
307    }
308
309    // --- Chat CRDT methods (requires `legacy-chat` feature) ---
310
311    /// Get the chat CRDT (if any)
312    #[cfg(feature = "legacy-chat")]
313    pub fn get_chat(&self) -> Option<&ChatCRDT> {
314        self.chat.as_ref()
315    }
316
317    /// Get mutable reference to the chat CRDT, creating it if needed
318    #[cfg(feature = "legacy-chat")]
319    pub fn get_or_create_chat(&mut self) -> &mut ChatCRDT {
320        if self.chat.is_none() {
321            self.chat = Some(ChatCRDT::new());
322        }
323        self.chat.as_mut().unwrap()
324    }
325
326    /// Add a chat message to the document
327    ///
328    /// Returns true if the message was new (not a duplicate)
329    #[cfg(feature = "legacy-chat")]
330    pub fn add_chat_message(
331        &mut self,
332        origin_node: u32,
333        timestamp: u64,
334        sender: &str,
335        text: &str,
336    ) -> bool {
337        use crate::sync::crdt::ChatMessage;
338
339        let mut msg = ChatMessage::new(origin_node, timestamp, sender, text);
340        msg.is_broadcast = true;
341
342        let chat = self.get_or_create_chat();
343        if chat.add_message(msg) {
344            self.increment_counter();
345            true
346        } else {
347            false
348        }
349    }
350
351    /// Add a chat message with reply-to information
352    #[cfg(feature = "legacy-chat")]
353    pub fn add_chat_reply(
354        &mut self,
355        origin_node: u32,
356        timestamp: u64,
357        sender: &str,
358        text: &str,
359        reply_to_node: u32,
360        reply_to_timestamp: u64,
361    ) -> bool {
362        use crate::sync::crdt::ChatMessage;
363
364        let mut msg = ChatMessage::new(origin_node, timestamp, sender, text);
365        msg.is_broadcast = true;
366        msg.set_reply_to(reply_to_node, reply_to_timestamp);
367
368        let chat = self.get_or_create_chat();
369        if chat.add_message(msg) {
370            self.increment_counter();
371            true
372        } else {
373            false
374        }
375    }
376
377    /// Check if the document has any chat messages
378    #[cfg(feature = "legacy-chat")]
379    pub fn has_chat(&self) -> bool {
380        self.chat.as_ref().is_some_and(|c| !c.is_empty())
381    }
382
383    /// Get the number of chat messages
384    #[cfg(feature = "legacy-chat")]
385    pub fn chat_count(&self) -> usize {
386        self.chat.as_ref().map_or(0, |c| c.len())
387    }
388
389    /// Merge with another document using CRDT semantics
390    ///
391    /// Returns true if our state changed (useful for triggering re-broadcast)
392    pub fn merge(&mut self, other: &HiveDocument) -> bool {
393        let mut changed = false;
394
395        // Merge counter
396        let old_value = self.counter.value();
397        self.counter.merge(&other.counter);
398        if self.counter.value() != old_value {
399            changed = true;
400        }
401
402        // Merge emergency event
403        if let Some(ref other_emergency) = other.emergency {
404            match &mut self.emergency {
405                Some(ref mut our_emergency) => {
406                    if our_emergency.merge(other_emergency) {
407                        changed = true;
408                    }
409                }
410                None => {
411                    self.emergency = Some(other_emergency.clone());
412                    changed = true;
413                }
414            }
415        }
416
417        // Merge chat CRDT
418        #[cfg(feature = "legacy-chat")]
419        if let Some(ref other_chat) = other.chat {
420            match &mut self.chat {
421                Some(ref mut our_chat) => {
422                    if our_chat.merge(other_chat) {
423                        changed = true;
424                    }
425                }
426                None => {
427                    if !other_chat.is_empty() {
428                        self.chat = Some(other_chat.clone());
429                        changed = true;
430                    }
431                }
432            }
433        }
434
435        if changed {
436            self.increment_version();
437        }
438        changed
439    }
440
441    /// Get the current event type (if any)
442    pub fn current_event(&self) -> Option<EventType> {
443        self.peripheral
444            .as_ref()
445            .and_then(|p| p.last_event.as_ref())
446            .map(|e| e.event_type)
447    }
448
449    /// Encode to bytes for BLE transmission
450    ///
451    /// Alias: [`Self::to_bytes()`]
452    pub fn encode(&self) -> Vec<u8> {
453        let counter_data = self.counter.encode();
454        let peripheral_data = self.peripheral.as_ref().map(|p| p.encode());
455        let emergency_data = self.emergency.as_ref().map(|e| e.encode());
456        #[cfg(feature = "legacy-chat")]
457        let chat_data = self
458            .chat
459            .as_ref()
460            .filter(|c| !c.is_empty())
461            .map(|c| c.encode());
462        #[cfg(not(feature = "legacy-chat"))]
463        let chat_data: Option<Vec<u8>> = None;
464
465        // Calculate total size
466        let mut size = 8 + counter_data.len(); // header + counter
467        if let Some(ref pdata) = peripheral_data {
468            size += 4 + pdata.len(); // marker + reserved + len + peripheral
469        }
470        if let Some(ref edata) = emergency_data {
471            size += 4 + edata.len(); // marker + reserved + len + emergency
472        }
473        if let Some(ref cdata) = chat_data {
474            size += 4 + cdata.len(); // marker + reserved + len + chat
475        }
476
477        let mut buf = Vec::with_capacity(size);
478
479        // Header
480        buf.extend_from_slice(&self.version.to_le_bytes());
481        buf.extend_from_slice(&self.node_id.as_u32().to_le_bytes());
482
483        // GCounter
484        buf.extend_from_slice(&counter_data);
485
486        // Extended section (if peripheral present)
487        if let Some(pdata) = peripheral_data {
488            buf.push(EXTENDED_MARKER);
489            buf.push(0); // reserved
490            buf.extend_from_slice(&(pdata.len() as u16).to_le_bytes());
491            buf.extend_from_slice(&pdata);
492        }
493
494        // Emergency section (if emergency present)
495        if let Some(edata) = emergency_data {
496            buf.push(EMERGENCY_MARKER);
497            buf.push(0); // reserved
498            buf.extend_from_slice(&(edata.len() as u16).to_le_bytes());
499            buf.extend_from_slice(&edata);
500        }
501
502        // Chat section (if chat has messages)
503        if let Some(cdata) = chat_data {
504            buf.push(CHAT_MARKER);
505            buf.push(0); // reserved
506            buf.extend_from_slice(&(cdata.len() as u16).to_le_bytes());
507            buf.extend_from_slice(&cdata);
508        }
509
510        buf
511    }
512
513    /// Encode to bytes for transmission (alias for [`Self::encode()`])
514    ///
515    /// This is the conventional name used by external crates like hive-ffi
516    /// for transport-agnostic document serialization.
517    #[inline]
518    pub fn to_bytes(&self) -> Vec<u8> {
519        self.encode()
520    }
521
522    /// Decode from bytes received over BLE
523    ///
524    /// Alias: [`Self::from_bytes()`]
525    pub fn decode(data: &[u8]) -> Option<Self> {
526        if data.len() < MIN_DOCUMENT_SIZE {
527            return None;
528        }
529
530        // Header
531        let version = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
532        let node_id = NodeId::new(u32::from_le_bytes([data[4], data[5], data[6], data[7]]));
533
534        // GCounter (starts at offset 8)
535        let counter = GCounter::decode(&data[8..])?;
536
537        // Calculate where counter ends
538        let num_entries = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
539        let mut offset = 8 + 4 + num_entries * 12;
540
541        let mut peripheral = None;
542        let mut emergency = None;
543        #[cfg(feature = "legacy-chat")]
544        let mut chat = None;
545
546        // Parse extended sections (can have peripheral, emergency, and/or chat)
547        while offset < data.len() {
548            let marker = data[offset];
549
550            if marker == EXTENDED_MARKER {
551                // Parse peripheral section
552                if data.len() < offset + 4 {
553                    break;
554                }
555                let _reserved = data[offset + 1];
556                let section_len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
557
558                let section_start = offset + 4;
559                if data.len() < section_start + section_len {
560                    break;
561                }
562
563                peripheral = Peripheral::decode(&data[section_start..section_start + section_len]);
564                offset = section_start + section_len;
565            } else if marker == EMERGENCY_MARKER {
566                // Parse emergency section
567                if data.len() < offset + 4 {
568                    break;
569                }
570                let _reserved = data[offset + 1];
571                let section_len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
572
573                let section_start = offset + 4;
574                if data.len() < section_start + section_len {
575                    break;
576                }
577
578                emergency =
579                    EmergencyEvent::decode(&data[section_start..section_start + section_len]);
580                offset = section_start + section_len;
581            } else if marker == CHAT_MARKER {
582                // Skip/parse chat section (silently skip if legacy-chat not enabled)
583                if data.len() < offset + 4 {
584                    break;
585                }
586                let _reserved = data[offset + 1];
587                let section_len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
588
589                let section_start = offset + 4;
590                if data.len() < section_start + section_len {
591                    break;
592                }
593
594                #[cfg(feature = "legacy-chat")]
595                {
596                    chat = ChatCRDT::decode(&data[section_start..section_start + section_len]);
597                }
598                offset = section_start + section_len;
599            } else {
600                // Unknown marker, stop parsing
601                break;
602            }
603        }
604
605        Some(Self {
606            version,
607            node_id,
608            counter,
609            peripheral,
610            emergency,
611            #[cfg(feature = "legacy-chat")]
612            chat,
613        })
614    }
615
616    /// Decode from bytes (alias for [`Self::decode()`])
617    ///
618    /// This is the conventional name used by external crates like hive-ffi
619    /// for transport-agnostic document deserialization.
620    #[inline]
621    pub fn from_bytes(data: &[u8]) -> Option<Self> {
622        Self::decode(data)
623    }
624
625    /// Get the total counter value
626    pub fn total_count(&self) -> u64 {
627        self.counter.value()
628    }
629
630    /// Get the encoded size of this document
631    ///
632    /// Use this to check if the document fits within BLE MTU constraints.
633    pub fn encoded_size(&self) -> usize {
634        let counter_size = 4 + self.counter.node_count_total() * 12;
635        let peripheral_size = self.peripheral.as_ref().map_or(0, |p| 4 + p.encode().len());
636        let emergency_size = self.emergency.as_ref().map_or(0, |e| 4 + e.encode().len());
637        #[cfg(feature = "legacy-chat")]
638        let chat_size = self
639            .chat
640            .as_ref()
641            .filter(|c| !c.is_empty())
642            .map_or(0, |c| 4 + c.encoded_size());
643        #[cfg(not(feature = "legacy-chat"))]
644        let chat_size = 0;
645        8 + counter_size + peripheral_size + emergency_size + chat_size
646    }
647
648    /// Check if the document exceeds the target size for single-packet transmission
649    ///
650    /// Returns `true` if the document is larger than [`TARGET_DOCUMENT_SIZE`].
651    pub fn exceeds_target_size(&self) -> bool {
652        self.encoded_size() > TARGET_DOCUMENT_SIZE
653    }
654
655    /// Check if the document exceeds the maximum size
656    ///
657    /// Returns `true` if the document is larger than [`MAX_DOCUMENT_SIZE`].
658    pub fn exceeds_max_size(&self) -> bool {
659        self.encoded_size() > MAX_DOCUMENT_SIZE
660    }
661}
662
663/// Result from merging a received document
664#[derive(Debug, Clone)]
665pub struct MergeResult {
666    /// Node ID that sent this document
667    pub source_node: NodeId,
668
669    /// Event contained in the document (if any)
670    pub event: Option<PeripheralEvent>,
671
672    /// Peripheral data from the document (if any)
673    ///
674    /// Contains the sender's callsign, location, health data etc.
675    /// This allows tracking peer state from received documents.
676    pub peer_peripheral: Option<Peripheral>,
677
678    /// Whether the counter changed (indicates new data)
679    pub counter_changed: bool,
680
681    /// Whether the emergency state changed (new emergency or ACK updates)
682    pub emergency_changed: bool,
683
684    /// Whether chat messages changed (new messages received)
685    pub chat_changed: bool,
686
687    /// Updated total count after merge
688    pub total_count: u64,
689}
690
691impl MergeResult {
692    /// Check if this result contains an emergency event
693    pub fn is_emergency(&self) -> bool {
694        self.event
695            .as_ref()
696            .is_some_and(|e| e.event_type == EventType::Emergency)
697    }
698
699    /// Check if this result contains an ACK event
700    pub fn is_ack(&self) -> bool {
701        self.event
702            .as_ref()
703            .is_some_and(|e| e.event_type == EventType::Ack)
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use crate::sync::crdt::PeripheralType;
711
712    // Valid timestamp for tests: 2024-01-15 00:00:00 UTC
713    const TEST_TIMESTAMP: u64 = 1705276800000;
714
715    #[test]
716    fn test_document_encode_decode_minimal() {
717        let node_id = NodeId::new(0x12345678);
718        let doc = HiveDocument::new(node_id);
719
720        let encoded = doc.encode();
721        assert_eq!(encoded.len(), 12); // 8 header + 4 counter (0 entries)
722
723        let decoded = HiveDocument::decode(&encoded).unwrap();
724        assert_eq!(decoded.version, 1);
725        assert_eq!(decoded.node_id.as_u32(), 0x12345678);
726        assert_eq!(decoded.counter.value(), 0);
727        assert!(decoded.peripheral.is_none());
728    }
729
730    #[test]
731    fn test_document_encode_decode_with_counter() {
732        let node_id = NodeId::new(0x12345678);
733        let mut doc = HiveDocument::new(node_id);
734        doc.increment_counter();
735        doc.increment_counter();
736
737        let encoded = doc.encode();
738        // 8 header + 4 num_entries + 1 entry (12 bytes) = 24
739        assert_eq!(encoded.len(), 24);
740
741        let decoded = HiveDocument::decode(&encoded).unwrap();
742        assert_eq!(decoded.counter.value(), 2);
743    }
744
745    #[test]
746    fn test_document_encode_decode_with_peripheral() {
747        let node_id = NodeId::new(0x12345678);
748        let peripheral =
749            Peripheral::new(0xAABBCCDD, PeripheralType::SoldierSensor).with_callsign("ALPHA-1");
750
751        let doc = HiveDocument::new(node_id).with_peripheral(peripheral);
752
753        let encoded = doc.encode();
754        let decoded = HiveDocument::decode(&encoded).unwrap();
755
756        assert!(decoded.peripheral.is_some());
757        let p = decoded.peripheral.unwrap();
758        assert_eq!(p.id, 0xAABBCCDD);
759        assert_eq!(p.callsign_str(), "ALPHA-1");
760    }
761
762    #[test]
763    fn test_document_encode_decode_with_event() {
764        let node_id = NodeId::new(0x12345678);
765        let mut peripheral = Peripheral::new(0xAABBCCDD, PeripheralType::SoldierSensor);
766        peripheral.set_event(EventType::Emergency, TEST_TIMESTAMP);
767
768        let doc = HiveDocument::new(node_id).with_peripheral(peripheral);
769
770        let encoded = doc.encode();
771        let decoded = HiveDocument::decode(&encoded).unwrap();
772
773        assert!(decoded.peripheral.is_some());
774        let p = decoded.peripheral.unwrap();
775        assert!(p.last_event.is_some());
776        let event = p.last_event.unwrap();
777        assert_eq!(event.event_type, EventType::Emergency);
778        assert_eq!(event.timestamp, TEST_TIMESTAMP);
779    }
780
781    #[test]
782    fn test_document_merge() {
783        let node1 = NodeId::new(0x11111111);
784        let node2 = NodeId::new(0x22222222);
785
786        let mut doc1 = HiveDocument::new(node1);
787        doc1.increment_counter();
788
789        let mut doc2 = HiveDocument::new(node2);
790        doc2.counter.increment(&node2, 3);
791
792        // Merge doc2 into doc1
793        let changed = doc1.merge(&doc2);
794        assert!(changed);
795        assert_eq!(doc1.counter.value(), 4); // 1 + 3
796    }
797
798    #[test]
799    fn test_merge_result_helpers() {
800        let emergency_event = PeripheralEvent::new(EventType::Emergency, 123);
801        let result = MergeResult {
802            source_node: NodeId::new(0x12345678),
803            event: Some(emergency_event),
804            peer_peripheral: None,
805            counter_changed: true,
806            emergency_changed: false,
807            chat_changed: false,
808            total_count: 10,
809        };
810
811        assert!(result.is_emergency());
812        assert!(!result.is_ack());
813
814        let ack_event = PeripheralEvent::new(EventType::Ack, 456);
815        let result = MergeResult {
816            source_node: NodeId::new(0x12345678),
817            event: Some(ack_event),
818            peer_peripheral: None,
819            counter_changed: false,
820            emergency_changed: false,
821            chat_changed: false,
822            total_count: 10,
823        };
824
825        assert!(!result.is_emergency());
826        assert!(result.is_ack());
827    }
828
829    #[test]
830    fn test_document_size_calculation() {
831        use crate::sync::crdt::PeripheralType;
832
833        let node_id = NodeId::new(0x12345678);
834
835        // Minimal document: 8 header + 4 counter (0 entries) = 12 bytes
836        let doc = HiveDocument::new(node_id);
837        assert_eq!(doc.encoded_size(), 12);
838        assert!(!doc.exceeds_target_size());
839
840        // With one counter entry: 8 + (4 + 12) = 24 bytes
841        let mut doc = HiveDocument::new(node_id);
842        doc.increment_counter();
843        assert_eq!(doc.encoded_size(), 24);
844
845        // With peripheral: adds ~42 bytes (4 marker/len + 38 data)
846        let peripheral = Peripheral::new(0xAABBCCDD, PeripheralType::SoldierSensor);
847        let doc = HiveDocument::new(node_id).with_peripheral(peripheral);
848        let encoded = doc.encode();
849        assert_eq!(doc.encoded_size(), encoded.len());
850
851        // Verify size stays under target for reasonable mesh
852        let mut doc = HiveDocument::new(node_id);
853        for i in 0..10 {
854            doc.counter.increment(&NodeId::new(i), 1);
855        }
856        assert!(doc.encoded_size() < TARGET_DOCUMENT_SIZE);
857        assert!(!doc.exceeds_max_size());
858    }
859
860    // ============================================================================
861    // Chat CRDT Document Tests (gated behind legacy-chat)
862    // ============================================================================
863
864    #[cfg(feature = "legacy-chat")]
865    mod chat_document_tests {
866        use super::*;
867
868        #[test]
869        fn test_document_add_chat_message() {
870            let node_id = NodeId::new(0x12345678);
871            let mut doc = HiveDocument::new(node_id);
872
873            assert!(!doc.has_chat());
874            assert_eq!(doc.chat_count(), 0);
875
876            // Add a message
877            assert!(doc.add_chat_message(0x12345678, TEST_TIMESTAMP, "ALPHA", "Hello mesh!"));
878            assert!(doc.has_chat());
879            assert_eq!(doc.chat_count(), 1);
880
881            // Duplicate should be rejected
882            assert!(!doc.add_chat_message(0x12345678, TEST_TIMESTAMP, "ALPHA", "Hello mesh!"));
883            assert_eq!(doc.chat_count(), 1);
884
885            // Different message should be accepted
886            assert!(doc.add_chat_message(
887                0x12345678,
888                TEST_TIMESTAMP + 1000,
889                "ALPHA",
890                "Second message"
891            ));
892            assert_eq!(doc.chat_count(), 2);
893        }
894
895        #[test]
896        fn test_document_add_chat_reply() {
897            let node_id = NodeId::new(0x12345678);
898            let mut doc = HiveDocument::new(node_id);
899
900            // Add original message
901            doc.add_chat_message(0xAABBCCDD, TEST_TIMESTAMP, "BRAVO", "Need assistance");
902
903            // Add reply
904            assert!(doc.add_chat_reply(
905                0x12345678,
906                TEST_TIMESTAMP + 1000,
907                "ALPHA",
908                "Copy that",
909                0xAABBCCDD,     // reply to node
910                TEST_TIMESTAMP  // reply to timestamp
911            ));
912
913            assert_eq!(doc.chat_count(), 2);
914
915            // Verify reply-to info
916            let chat = doc.get_chat().unwrap();
917            let reply = chat.get_message(0x12345678, TEST_TIMESTAMP + 1000).unwrap();
918            assert!(reply.is_reply());
919            assert_eq!(reply.reply_to_node, 0xAABBCCDD);
920            assert_eq!(reply.reply_to_timestamp, TEST_TIMESTAMP);
921        }
922
923        #[test]
924        fn test_document_encode_decode_with_chat() {
925            let node_id = NodeId::new(0x12345678);
926            let mut doc = HiveDocument::new(node_id);
927
928            doc.add_chat_message(0x12345678, TEST_TIMESTAMP, "ALPHA", "First message");
929            doc.add_chat_message(0xAABBCCDD, TEST_TIMESTAMP + 1000, "BRAVO", "Second message");
930
931            let encoded = doc.encode();
932            let decoded = HiveDocument::decode(&encoded).unwrap();
933
934            assert!(decoded.has_chat());
935            assert_eq!(decoded.chat_count(), 2);
936
937            let chat = decoded.get_chat().unwrap();
938            let msg1 = chat.get_message(0x12345678, TEST_TIMESTAMP).unwrap();
939            assert_eq!(msg1.sender(), "ALPHA");
940            assert_eq!(msg1.text(), "First message");
941
942            let msg2 = chat.get_message(0xAABBCCDD, TEST_TIMESTAMP + 1000).unwrap();
943            assert_eq!(msg2.sender(), "BRAVO");
944            assert_eq!(msg2.text(), "Second message");
945        }
946
947        #[test]
948        fn test_document_merge_with_chat() {
949            let node1 = NodeId::new(0x11111111);
950            let node2 = NodeId::new(0x22222222);
951
952            let mut doc1 = HiveDocument::new(node1);
953            doc1.add_chat_message(0x11111111, TEST_TIMESTAMP, "ALPHA", "From node 1");
954
955            let mut doc2 = HiveDocument::new(node2);
956            doc2.add_chat_message(0x22222222, TEST_TIMESTAMP + 1000, "BRAVO", "From node 2");
957
958            // Merge doc2 into doc1
959            let changed = doc1.merge(&doc2);
960            assert!(changed);
961            assert_eq!(doc1.chat_count(), 2);
962
963            // Merge again - no changes
964            let changed = doc1.merge(&doc2);
965            assert!(!changed);
966
967            // Verify both messages present
968            let chat = doc1.get_chat().unwrap();
969            assert!(chat.get_message(0x11111111, TEST_TIMESTAMP).is_some());
970            assert!(chat
971                .get_message(0x22222222, TEST_TIMESTAMP + 1000)
972                .is_some());
973        }
974
975        #[test]
976        fn test_document_chat_encoded_size() {
977            let node_id = NodeId::new(0x12345678);
978            let mut doc = HiveDocument::new(node_id);
979
980            let base_size = doc.encoded_size();
981
982            // Add a message
983            doc.add_chat_message(0x12345678, TEST_TIMESTAMP, "ALPHA", "Test");
984
985            // Size should increase
986            let with_chat_size = doc.encoded_size();
987            assert!(with_chat_size > base_size);
988
989            // Encoded size should match actual encoded length
990            let encoded = doc.encode();
991            assert_eq!(doc.encoded_size(), encoded.len());
992        }
993    } // mod chat_document_tests
994}