Skip to main content

vector_core/
compact.rs

1//! Compact message storage with binary IDs and interned strings.
2//!
3//! Part of vector-core — the single source of truth for compact message types.
4//!
5//! This module provides memory-efficient message storage:
6//! - `[u8; 32]` for IDs instead of hex strings (saves ~56 bytes per ID)
7//! - Interned npubs via `NpubInterner` (each unique npub stored once)
8//! - Bitflags for boolean states (1 byte instead of 4+)
9//! - Binary search for O(log n) message lookup
10//! - Boxed optional fields (replied_to, wrapper_id) to save inline space
11//! - Compact timestamp (u32 seconds since 2020 epoch)
12
13use crate::types::{Attachment, EditEntry, ImageMetadata, Reaction, SiteMetadata};
14use crate::simd::hex::{bytes_to_hex_32, bytes_to_hex_string, hex_to_bytes_32};
15
16/// Decode a hex string of up to 32 hex chars into [u8; 16], left-aligned.
17/// Pads short inputs with '0' on the right before decoding.
18fn hex_to_bytes_16(hex: &str) -> [u8; 16] {
19    let mut out = [0u8; 16];
20    let h = hex.as_bytes();
21    // Pad to 32 hex chars with '0'
22    let mut padded = [b'0'; 32];
23    let copy_len = h.len().min(32);
24    padded[..copy_len].copy_from_slice(&h[..copy_len]);
25    for i in 0..16 {
26        let hi = hex_nibble(padded[i * 2]);
27        let lo = hex_nibble(padded[i * 2 + 1]);
28        out[i] = (hi << 4) | lo;
29    }
30    out
31}
32
33#[inline]
34fn hex_nibble(b: u8) -> u8 {
35    match b {
36        b'0'..=b'9' => b - b'0',
37        b'a'..=b'f' => b - b'a' + 10,
38        b'A'..=b'F' => b - b'A' + 10,
39        _ => 0,
40    }
41}
42
43// ============================================================================
44// Pending ID Encoding
45// ============================================================================
46
47/// Marker byte for pending IDs (first byte = 0x01).
48const PENDING_ID_MARKER: u8 = 0x01;
49
50/// Trailing sentinel for encoded pending ids (bytes 17..32). The marker byte
51/// alone is NOT safe: real event ids are uniform hashes, so 1 in 256 begins
52/// with 0x01 and would decode as a phantom "pending-…" string — mangling read
53/// markers, frontend-facing ids, every decode consumer. Requiring marker AND
54/// sentinel makes that misread a ~2^-120 event.
55const PENDING_ID_SENTINEL: [u8; 15] = [0xFE; 15];
56
57/// Encode an ID string to 32 bytes, handling pending IDs specially.
58/// - Pending IDs ("pending-{nanoseconds}") are encoded as marker byte +
59///   timestamp + sentinel tail
60/// - Regular hex IDs are decoded normally
61#[inline]
62pub fn encode_message_id(id: &str) -> [u8; 32] {
63    if let Some(timestamp_str) = id.strip_prefix("pending-") {
64        // Encode pending ID: marker byte + timestamp as u128 (16 bytes)
65        let mut bytes = [0u8; 32];
66        bytes[0] = PENDING_ID_MARKER;
67        if let Ok(timestamp) = timestamp_str.parse::<u128>() {
68            bytes[1..17].copy_from_slice(&timestamp.to_le_bytes());
69        }
70        bytes[17..32].copy_from_slice(&PENDING_ID_SENTINEL);
71        bytes
72    } else {
73        hex_to_bytes_32(id)
74    }
75}
76
77/// Decode 32 bytes back to an ID string, handling pending IDs specially.
78#[inline]
79pub fn decode_message_id(bytes: &[u8; 32]) -> String {
80    if bytes[0] == PENDING_ID_MARKER && bytes[17..32] == PENDING_ID_SENTINEL {
81        // Decode pending ID: extract timestamp from bytes 1-16
82        let mut timestamp_bytes = [0u8; 16];
83        timestamp_bytes.copy_from_slice(&bytes[1..17]);
84        let timestamp = u128::from_le_bytes(timestamp_bytes);
85        format!("pending-{}", timestamp)
86    } else {
87        bytes_to_hex_32(bytes)
88    }
89}
90
91// ============================================================================
92// Compact Timestamp
93// ============================================================================
94
95/// Convert milliseconds timestamp to compact storage.
96/// Stores full u64 milliseconds — no precision loss.
97#[inline]
98pub fn timestamp_to_compact(ms: u64) -> u64 {
99    ms
100}
101
102/// Convert compact timestamp back to milliseconds.
103#[inline]
104pub fn timestamp_from_compact(compact: u64) -> u64 {
105    compact
106}
107
108/// Custom epoch in seconds: 2020-01-01 00:00:00 UTC
109const EPOCH_2020_SECS: u64 = 1577836800;
110
111/// Convert Unix seconds timestamp to compact u32 (seconds since 2020).
112/// Preserves 0 as sentinel for "never set".
113#[inline]
114pub fn secs_to_compact(secs: u64) -> u32 {
115    if secs == 0 { return 0; }
116    secs.saturating_sub(EPOCH_2020_SECS) as u32
117}
118
119/// Convert compact u32 back to Unix seconds timestamp.
120/// Preserves 0 as sentinel for "never set".
121#[inline]
122pub fn secs_from_compact(compact: u32) -> u64 {
123    if compact == 0 { return 0; }
124    EPOCH_2020_SECS + compact as u64
125}
126
127// ============================================================================
128// Message Flags
129// ============================================================================
130
131/// Bitflags for message state (1 byte instead of 4+ bytes for separate bools)
132///
133/// Layout (bits): 0=mine, 1=pending, 2=failed, 3-4=replied_to_has_attachment
134/// replied_to_has_attachment: 00=None, 01=Some(false), 10=Some(true)
135///
136/// Note: No EDITED flag - check `edit_history.is_some()` instead
137#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
138pub struct MessageFlags(u8);
139
140impl MessageFlags {
141    pub const NONE: Self = Self(0);
142    pub const MINE: Self = Self(0b00001);
143    pub const PENDING: Self = Self(0b00010);
144    pub const FAILED: Self = Self(0b00100);
145    // Bits 3-4 for replied_to_has_attachment:
146    // 00 = None, 01 = Some(false), 10 = Some(true)
147    const REPLY_ATTACH_MASK: u8 = 0b11000;
148    const REPLY_ATTACH_SHIFT: u8 = 3;
149
150    #[inline]
151    pub fn is_mine(self) -> bool {
152        self.0 & Self::MINE.0 != 0
153    }
154
155    #[inline]
156    pub fn is_pending(self) -> bool {
157        self.0 & Self::PENDING.0 != 0
158    }
159
160    #[inline]
161    pub fn is_failed(self) -> bool {
162        self.0 & Self::FAILED.0 != 0
163    }
164
165    /// Get replied_to_has_attachment as Option<bool>
166    /// Returns None (unknown), Some(false), or Some(true)
167    #[inline]
168    pub fn replied_to_has_attachment(self) -> Option<bool> {
169        match (self.0 & Self::REPLY_ATTACH_MASK) >> Self::REPLY_ATTACH_SHIFT {
170            0b00 => None,           // Unknown
171            0b01 => Some(false),    // No attachment
172            0b10 => Some(true),     // Has attachment
173            _ => None,              // Invalid, treat as unknown
174        }
175    }
176
177    #[inline]
178    pub fn set_mine(&mut self, value: bool) {
179        if value {
180            self.0 |= Self::MINE.0;
181        } else {
182            self.0 &= !Self::MINE.0;
183        }
184    }
185
186    #[inline]
187    pub fn set_pending(&mut self, value: bool) {
188        if value {
189            self.0 |= Self::PENDING.0;
190        } else {
191            self.0 &= !Self::PENDING.0;
192        }
193    }
194
195    #[inline]
196    pub fn set_failed(&mut self, value: bool) {
197        if value {
198            self.0 |= Self::FAILED.0;
199        } else {
200            self.0 &= !Self::FAILED.0;
201        }
202    }
203
204    /// Set replied_to_has_attachment from Option<bool>
205    #[inline]
206    pub fn set_replied_to_has_attachment(&mut self, value: Option<bool>) {
207        // Clear existing bits
208        self.0 &= !Self::REPLY_ATTACH_MASK;
209        // Set new value
210        let bits = match value {
211            None => 0b00,
212            Some(false) => 0b01,
213            Some(true) => 0b10,
214        };
215        self.0 |= bits << Self::REPLY_ATTACH_SHIFT;
216    }
217
218    /// Create flags from individual booleans
219    #[inline]
220    pub fn from_bools(mine: bool, pending: bool, failed: bool) -> Self {
221        let mut flags = Self::NONE;
222        flags.set_mine(mine);
223        flags.set_pending(pending);
224        flags.set_failed(failed);
225        flags
226    }
227
228    /// Create flags from all values including replied_to_has_attachment
229    #[inline]
230    pub fn from_all(mine: bool, pending: bool, failed: bool, replied_to_has_attachment: Option<bool>) -> Self {
231        let mut flags = Self::from_bools(mine, pending, failed);
232        flags.set_replied_to_has_attachment(replied_to_has_attachment);
233        flags
234    }
235}
236
237// ============================================================================
238// TinyVec - 8-byte thin pointer for small collections
239// ============================================================================
240
241use std::alloc::{alloc, dealloc, Layout};
242use std::marker::PhantomData;
243use std::ptr::NonNull;
244
245/// Ultra-compact vector using a thin pointer (8 bytes on stack).
246///
247/// Memory layout:
248/// - Stack: single pointer (8 bytes) - null for empty
249/// - Heap: `[len: u8][items: T...]` - only allocated when non-empty
250///
251/// Compared to standard types:
252/// - `Vec<T>`: 24 bytes (ptr + len + cap)
253/// - `Box<[T]>`: 16 bytes (fat pointer)
254/// - `TinyVec<T>`: 8 bytes (thin pointer)
255///
256/// Limitations:
257/// - Max 255 items (u8 length)
258/// - Immutable after creation (no push/pop - recreate to modify)
259/// - Perfect for attachments/reactions which rarely change
260pub struct TinyVec<T> {
261    /// Null = empty, otherwise points to: [len: u8][items: T...]
262    ptr: Option<NonNull<u8>>,
263    _marker: PhantomData<T>,
264}
265
266impl<T> TinyVec<T> {
267    /// Create an empty TinyVec (no allocation)
268    #[inline]
269    pub const fn new() -> Self {
270        Self {
271            ptr: None,
272            _marker: PhantomData,
273        }
274    }
275
276    /// Create from a Vec, consuming it
277    pub fn from_vec(vec: Vec<T>) -> Self {
278        if vec.is_empty() {
279            return Self::new();
280        }
281
282        let len = vec.len().min(255) as u8;
283
284        // Calculate layout: 1 byte for length + items
285        let (layout, items_offset) = Self::layout_for(len as usize);
286
287        unsafe {
288            // Allocate
289            let ptr = alloc(layout);
290            if ptr.is_null() {
291                std::alloc::handle_alloc_error(layout);
292            }
293
294            // Write length
295            *ptr = len;
296
297            // Move items (no clone!)
298            let items_ptr = ptr.add(items_offset) as *mut T;
299            for (i, item) in vec.into_iter().take(len as usize).enumerate() {
300                std::ptr::write(items_ptr.add(i), item);
301            }
302
303            Self {
304                ptr: NonNull::new(ptr),
305                _marker: PhantomData,
306            }
307        }
308    }
309
310    /// Calculate layout for allocation
311    fn layout_for(len: usize) -> (Layout, usize) {
312        let header_layout = Layout::new::<u8>();
313        let items_layout = Layout::array::<T>(len).unwrap();
314        header_layout.extend(items_layout).unwrap()
315    }
316
317    /// Number of items
318    #[inline]
319    pub fn len(&self) -> usize {
320        match self.ptr {
321            None => 0,
322            Some(ptr) => unsafe { *ptr.as_ptr() as usize },
323        }
324    }
325
326    #[inline]
327    pub fn is_empty(&self) -> bool {
328        self.ptr.is_none()
329    }
330
331    /// Get items offset within allocation
332    #[inline]
333    fn items_offset() -> usize {
334        let header_layout = Layout::new::<u8>();
335        let items_layout = Layout::new::<T>();
336        header_layout.extend(items_layout).map(|(_, offset)| offset).unwrap_or(1)
337    }
338
339    /// Get a slice of the items
340    #[inline]
341    pub fn as_slice(&self) -> &[T] {
342        match self.ptr {
343            None => &[],
344            Some(ptr) => unsafe {
345                let base = ptr.as_ptr();
346                let len = *base as usize;
347                let items_ptr = base.add(Self::items_offset()) as *const T;
348                std::slice::from_raw_parts(items_ptr, len)
349            },
350        }
351    }
352
353    /// Get a mutable slice of the items
354    #[inline]
355    pub fn as_mut_slice(&mut self) -> &mut [T] {
356        match self.ptr {
357            None => &mut [],
358            Some(ptr) => unsafe {
359                let base = ptr.as_ptr();
360                let len = *base as usize;
361                let items_ptr = base.add(Self::items_offset()) as *mut T;
362                std::slice::from_raw_parts_mut(items_ptr, len)
363            },
364        }
365    }
366
367    /// Iterate over items
368    #[inline]
369    pub fn iter(&self) -> std::slice::Iter<'_, T> {
370        self.as_slice().iter()
371    }
372
373    /// Iterate mutably
374    #[inline]
375    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, T> {
376        self.as_mut_slice().iter_mut()
377    }
378
379    /// Convert to Vec (clones items)
380    pub fn to_vec(&self) -> Vec<T>
381    where
382        T: Clone,
383    {
384        self.as_slice().to_vec()
385    }
386
387    /// Get the first item (immutable)
388    #[inline]
389    pub fn first(&self) -> Option<&T> {
390        self.as_slice().first()
391    }
392
393    /// Get the last item (immutable)
394    #[inline]
395    pub fn last(&self) -> Option<&T> {
396        self.as_slice().last()
397    }
398
399    /// Get the last item (mutable)
400    #[inline]
401    pub fn last_mut(&mut self) -> Option<&mut T> {
402        self.as_mut_slice().last_mut()
403    }
404
405    /// Get item by index (immutable)
406    #[inline]
407    pub fn get(&self, index: usize) -> Option<&T> {
408        self.as_slice().get(index)
409    }
410
411    /// Get item by index (mutable)
412    #[inline]
413    pub fn get_mut(&mut self, index: usize) -> Option<&mut T> {
414        self.as_mut_slice().get_mut(index)
415    }
416
417    /// Push an item (rebuilds the entire allocation - use sparingly!)
418    pub fn push(&mut self, item: T)
419    where
420        T: Clone,
421    {
422        let mut vec = self.to_vec();
423        vec.push(item);
424        *self = Self::from_vec(vec);
425    }
426
427    /// Retain items matching a predicate (rebuilds the allocation)
428    pub fn retain<F>(&mut self, f: F)
429    where
430        T: Clone,
431        F: FnMut(&T) -> bool,
432    {
433        let mut vec = self.to_vec();
434        vec.retain(f);
435        *self = Self::from_vec(vec);
436    }
437
438    /// Check if any item matches a predicate
439    pub fn any<F>(&self, f: F) -> bool
440    where
441        F: FnMut(&T) -> bool,
442    {
443        self.as_slice().iter().any(f)
444    }
445}
446
447// Index trait for direct indexing (msg.attachments[0])
448impl<T> std::ops::Index<usize> for TinyVec<T> {
449    type Output = T;
450
451    fn index(&self, index: usize) -> &Self::Output {
452        &self.as_slice()[index]
453    }
454}
455
456impl<T> std::ops::IndexMut<usize> for TinyVec<T> {
457    fn index_mut(&mut self, index: usize) -> &mut Self::Output {
458        &mut self.as_mut_slice()[index]
459    }
460}
461
462// IntoIterator for &TinyVec
463impl<'a, T> IntoIterator for &'a TinyVec<T> {
464    type Item = &'a T;
465    type IntoIter = std::slice::Iter<'a, T>;
466
467    fn into_iter(self) -> Self::IntoIter {
468        self.as_slice().iter()
469    }
470}
471
472// IntoIterator for &mut TinyVec
473impl<'a, T> IntoIterator for &'a mut TinyVec<T> {
474    type Item = &'a mut T;
475    type IntoIter = std::slice::IterMut<'a, T>;
476
477    fn into_iter(self) -> Self::IntoIter {
478        self.as_mut_slice().iter_mut()
479    }
480}
481
482impl<T> Default for TinyVec<T> {
483    fn default() -> Self {
484        Self::new()
485    }
486}
487
488impl<T: Clone> Clone for TinyVec<T> {
489    fn clone(&self) -> Self {
490        Self::from_vec(self.to_vec())
491    }
492}
493
494impl<T: std::fmt::Debug> std::fmt::Debug for TinyVec<T> {
495    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496        f.debug_list().entries(self.as_slice()).finish()
497    }
498}
499
500impl<T> Drop for TinyVec<T> {
501    fn drop(&mut self) {
502        if let Some(ptr) = self.ptr {
503            unsafe {
504                let base = ptr.as_ptr();
505                let len = *base as usize;
506                let items_ptr = base.add(Self::items_offset()) as *mut T;
507
508                // Drop each item
509                for i in 0..len {
510                    std::ptr::drop_in_place(items_ptr.add(i));
511                }
512
513                // Deallocate
514                let (layout, _) = Self::layout_for(len);
515                dealloc(base, layout);
516            }
517        }
518    }
519}
520
521// Safety: TinyVec is Send/Sync if T is
522unsafe impl<T: Send> Send for TinyVec<T> {}
523unsafe impl<T: Sync> Sync for TinyVec<T> {}
524
525// ============================================================================
526// Compact Reaction
527// ============================================================================
528
529/// Memory-efficient reaction with binary IDs and interned author.
530///
531/// Compared to the regular `Reaction` struct (~292 bytes with heap):
532/// - IDs use `[u8; 32]` instead of hex String (saves ~56 bytes each)
533/// - Author uses u16 index into interner (saves ~86 bytes)
534/// - Emoji uses Box<str> (saves 8 bytes, supports custom emoji like `:cat_heart_eyes:`)
535/// - Total: ~82 bytes vs ~292 bytes (72% savings!)
536#[derive(Clone, Debug)]
537pub struct CompactReaction {
538    /// Reaction event ID as binary
539    pub id: [u8; 32],
540    /// Message being reacted to (binary event ID)
541    pub reference_id: [u8; 32],
542    /// Author npub index (interned via NpubInterner)
543    pub author_idx: u16,
544    /// Emoji string (supports standard emoji and custom like `:cat_heart_eyes:`)
545    pub emoji: Box<str>,
546    /// NIP-30 custom-emoji URL when the reaction is `:shortcode:` form.
547    /// Boxed so the cold path (stock unicode reactions) stays 8 bytes.
548    pub emoji_url: Option<Box<str>>,
549}
550
551impl CompactReaction {
552    /// Get reaction ID as hex string
553    #[inline]
554    pub fn id_hex(&self) -> String {
555        bytes_to_hex_32(&self.id)
556    }
557
558    /// Get reference ID as hex string
559    #[inline]
560    pub fn reference_id_hex(&self) -> String {
561        bytes_to_hex_32(&self.reference_id)
562    }
563
564    /// Convert from regular Reaction, interning author
565    pub fn from_reaction(reaction: &Reaction, interner: &mut NpubInterner) -> Self {
566        Self {
567            id: hex_to_bytes_32(&reaction.id),
568            reference_id: hex_to_bytes_32(&reaction.reference_id),
569            author_idx: interner.intern(&reaction.author_id),
570            emoji: reaction.emoji.clone().into_boxed_str(),
571            emoji_url: reaction.emoji_url.as_deref().map(|s| s.into()),
572        }
573    }
574
575    /// Convert from regular Reaction (owned), interning author
576    pub fn from_reaction_owned(reaction: Reaction, interner: &mut NpubInterner) -> Self {
577        Self {
578            id: hex_to_bytes_32(&reaction.id),
579            reference_id: hex_to_bytes_32(&reaction.reference_id),
580            author_idx: interner.intern(&reaction.author_id),
581            emoji: reaction.emoji.into_boxed_str(),
582            emoji_url: reaction.emoji_url.map(|s| s.into_boxed_str()),
583        }
584    }
585
586    /// Convert back to regular Reaction, resolving author from interner
587    pub fn to_reaction(&self, interner: &NpubInterner) -> Reaction {
588        Reaction {
589            id: self.id_hex(),
590            reference_id: self.reference_id_hex(),
591            author_id: interner.resolve(self.author_idx)
592                .map(|s| s.to_string())
593                .unwrap_or_default(),
594            emoji: self.emoji.to_string(),
595            emoji_url: self.emoji_url.as_deref().map(|s| s.to_string()),
596        }
597    }
598}
599
600// ============================================================================
601// Compact Attachment
602// ============================================================================
603
604/// Packed flags for attachment state (1 byte instead of multiple bools)
605#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
606pub struct AttachmentFlags(u8);
607
608impl AttachmentFlags {
609    pub const NONE: Self = Self(0);
610    const DOWNLOADING: u8 = 0b0001;
611    const DOWNLOADED: u8  = 0b0010;
612    const SHORT_NONCE: u8 = 0b0100; // 12-byte nonce (legacy) vs 16-byte (DM)
613
614    #[inline]
615    pub fn is_downloading(self) -> bool { self.0 & Self::DOWNLOADING != 0 }
616    #[inline]
617    pub fn is_downloaded(self) -> bool { self.0 & Self::DOWNLOADED != 0 }
618    #[inline]
619    pub fn is_short_nonce(self) -> bool { self.0 & Self::SHORT_NONCE != 0 }
620
621    #[inline]
622    pub fn set_downloading(&mut self, value: bool) {
623        if value { self.0 |= Self::DOWNLOADING; } else { self.0 &= !Self::DOWNLOADING; }
624    }
625    #[inline]
626    pub fn set_downloaded(&mut self, value: bool) {
627        if value { self.0 |= Self::DOWNLOADED; } else { self.0 &= !Self::DOWNLOADED; }
628    }
629    #[inline]
630    pub fn set_short_nonce(&mut self, value: bool) {
631        if value { self.0 |= Self::SHORT_NONCE; } else { self.0 &= !Self::SHORT_NONCE; }
632    }
633
634    pub fn from_bools(downloading: bool, downloaded: bool) -> Self {
635        let mut flags = Self::NONE;
636        flags.set_downloading(downloading);
637        flags.set_downloaded(downloaded);
638        flags
639    }
640}
641
642/// Memory-efficient attachment with binary hashes and compact strings.
643///
644/// Compared to the regular `Attachment` struct (~320+ bytes):
645/// - id (SHA256): `[u8; 32]` instead of hex String (saves ~56 bytes)
646/// - key: `[u8; 32]` instead of String (saves ~56 bytes)
647/// - nonce: `[u8; 16]` instead of String (saves ~32 bytes)
648/// - Bools packed into AttachmentFlags (saves padding)
649/// - Strings use Box<str> (saves 8 bytes each)
650/// - Rare fields boxed (saves ~100+ bytes when None)
651/// - Total: ~120 bytes vs ~320 bytes (62% savings!)
652#[derive(Clone, Debug)]
653pub struct CompactAttachment {
654    // === Fixed binary fields ===
655    /// SHA256 file hash as binary (was hex String)
656    pub id: [u8; 32],
657    /// Encryption key - 32 bytes (empty = legacy derived)
658    pub key: [u8; 32],
659    /// Encryption nonce - 16 bytes (AES-256-GCM with 0xChat compatibility)
660    pub nonce: [u8; 16],
661    /// File size in bytes
662    pub size: u64,
663    /// Packed boolean flags (downloading, downloaded)
664    pub flags: AttachmentFlags,
665
666    // === Variable fields (Box<str> = 16 bytes each vs String's 24) ===
667    /// File extension (e.g., "png", "mp4")
668    pub extension: Box<str>,
669    /// Host URL (blossom server, etc.)
670    pub url: Box<str>,
671    /// Local file path (empty if not downloaded)
672    pub path: Box<str>,
673
674    // === Optional fields - boxed to save space when None ===
675    /// Image metadata (only for images/videos)
676    pub img_meta: Option<Box<ImageMetadata>>,
677    /// Legacy group ID for key derivation
678    pub group_id: Option<Box<[u8; 32]>>,
679    /// Original file hash before encryption
680    pub original_hash: Option<Box<[u8; 32]>>,
681    /// WebXDC topic (Mini Apps only - very rare)
682    pub webxdc_topic: Option<Box<str>>,
683    /// Legacy filename for AAD
684    pub mls_filename: Option<Box<str>>,
685    /// Scheme version (e.g., "mip04-v1")
686    pub scheme_version: Option<Box<str>>,
687    /// Original filename (e.g. "memories.zip"). Empty = fallback to {hash}.{ext}
688    pub name: String,
689}
690
691impl CompactAttachment {
692    // === Convenience accessors for flags ===
693    #[inline]
694    pub fn downloaded(&self) -> bool { self.flags.is_downloaded() }
695    #[inline]
696    pub fn downloading(&self) -> bool { self.flags.is_downloading() }
697    #[inline]
698    pub fn set_downloaded(&mut self, value: bool) { self.flags.set_downloaded(value); }
699    #[inline]
700    pub fn set_downloading(&mut self, value: bool) { self.flags.set_downloading(value); }
701
702    /// Check if this attachment's ID matches a hex string
703    #[inline]
704    pub fn id_eq(&self, hex_id: &str) -> bool {
705        self.id == hex_to_bytes_32(hex_id)
706    }
707
708    /// Get file ID as hex string
709    #[inline]
710    pub fn id_hex(&self) -> String {
711        bytes_to_hex_32(&self.id)
712    }
713
714    /// Get encryption key as hex string (empty if zeros)
715    pub fn key_hex(&self) -> String {
716        if self.key == [0u8; 32] {
717            String::new()
718        } else {
719            bytes_to_hex_32(&self.key)
720        }
721    }
722
723    /// Get nonce as hex string (empty if zeros, respects original length)
724    pub fn nonce_hex(&self) -> String {
725        if self.nonce == [0u8; 16] {
726            String::new()
727        } else if self.flags.is_short_nonce() {
728            // 12-byte nonce (legacy/MIP-04)
729            bytes_to_hex_string(&self.nonce[..12])
730        } else {
731            // 16-byte nonce (DM/0xChat)
732            bytes_to_hex_string(&self.nonce)
733        }
734    }
735
736    /// Convert from regular Attachment (borrowed)
737    pub fn from_attachment(att: &Attachment) -> Self {
738        // Detect short nonce (12 bytes = 24 hex chars) for legacy attachments
739        let is_short_nonce = att.nonce.len() == 24;
740        let mut flags = AttachmentFlags::from_bools(att.downloading, att.downloaded);
741        flags.set_short_nonce(is_short_nonce);
742
743        Self {
744            id: hex_to_bytes_32(&att.id),
745            key: if att.key.is_empty() { [0u8; 32] } else { hex_to_bytes_32(&att.key) },
746            nonce: if att.nonce.is_empty() { [0u8; 16] } else { parse_nonce(&att.nonce) },
747            size: att.size,
748            flags,
749            extension: att.extension.clone().into_boxed_str(),
750            url: att.url.clone().into_boxed_str(),
751            path: att.path.clone().into_boxed_str(),
752            img_meta: att.img_meta.clone().map(Box::new),
753            group_id: att.group_id.as_ref().map(|s| Box::new(hex_to_bytes_32(s))),
754            original_hash: att.original_hash.as_ref().map(|s| Box::new(hex_to_bytes_32(s))),
755            webxdc_topic: att.webxdc_topic.clone().map(|s| s.into_boxed_str()),
756            mls_filename: att.mls_filename.clone().map(|s| s.into_boxed_str()),
757            scheme_version: att.scheme_version.clone().map(|s| s.into_boxed_str()),
758            name: att.name.clone(),
759        }
760    }
761
762    /// Convert from regular Attachment (owned) - zero-copy where possible
763    pub fn from_attachment_owned(att: Attachment) -> Self {
764        // Detect short nonce (12 bytes = 24 hex chars) for legacy attachments
765        let is_short_nonce = att.nonce.len() == 24;
766        let mut flags = AttachmentFlags::from_bools(att.downloading, att.downloaded);
767        flags.set_short_nonce(is_short_nonce);
768
769        Self {
770            id: hex_to_bytes_32(&att.id),
771            key: if att.key.is_empty() { [0u8; 32] } else { hex_to_bytes_32(&att.key) },
772            nonce: if att.nonce.is_empty() { [0u8; 16] } else { parse_nonce(&att.nonce) },
773            size: att.size,
774            flags,
775            extension: att.extension.into_boxed_str(),
776            url: att.url.into_boxed_str(),
777            path: att.path.into_boxed_str(),
778            img_meta: att.img_meta.map(Box::new),
779            group_id: att.group_id.map(|s| Box::new(hex_to_bytes_32(&s))),
780            original_hash: att.original_hash.map(|s| Box::new(hex_to_bytes_32(&s))),
781            webxdc_topic: att.webxdc_topic.map(|s| s.into_boxed_str()),
782            mls_filename: att.mls_filename.map(|s| s.into_boxed_str()),
783            scheme_version: att.scheme_version.map(|s| s.into_boxed_str()),
784            name: att.name,
785        }
786    }
787
788    /// Convert back to regular Attachment
789    pub fn to_attachment(&self) -> Attachment {
790        Attachment {
791            id: self.id_hex(),
792            key: self.key_hex(),
793            nonce: self.nonce_hex(),
794            extension: self.extension.to_string(),
795            name: self.name.clone(),
796            url: self.url.to_string(),
797            path: self.path.to_string(),
798            size: self.size,
799            img_meta: self.img_meta.as_ref().map(|b| (**b).clone()),
800            downloading: self.flags.is_downloading(),
801            downloaded: self.flags.is_downloaded(),
802            webxdc_topic: self.webxdc_topic.as_ref().map(|s| s.to_string()),
803            group_id: self.group_id.as_ref().map(|b| bytes_to_hex_32(b)),
804            original_hash: self.original_hash.as_ref().map(|b| bytes_to_hex_32(b)),
805            scheme_version: self.scheme_version.as_ref().map(|s| s.to_string()),
806            mls_filename: self.mls_filename.as_ref().map(|s| s.to_string()),
807        }
808    }
809}
810
811/// Parse a hex nonce string into [u8; 16], left-aligned, zero-allocation.
812/// Both DM (32 hex chars) and legacy (24 hex chars) nonces are decoded.
813/// Short nonces are right-padded with '0' to reach 32 chars before decode.
814#[inline]
815fn parse_nonce(hex: &str) -> [u8; 16] {
816    hex_to_bytes_16(hex)
817}
818
819// ============================================================================
820// Npub Interner
821// ============================================================================
822
823/// String interner for npubs using sorted Vec + binary search.
824///
825/// Each unique npub is stored exactly once. Messages reference npubs by u16 index.
826/// - `intern()`: O(log n) lookup + O(n) insert for new strings
827/// - `resolve()`: O(1) by index
828///
829/// Memory: ~2 bytes per npub for the sorted index, plus the strings themselves.
830#[derive(Clone, Debug, Default)]
831pub struct NpubInterner {
832    /// npubs in insertion order - index is the stable ID used by messages
833    npubs: Vec<String>,
834    /// Indices into npubs, sorted alphabetically for binary search
835    sorted: Vec<u16>,
836}
837
838/// Sentinel value for "no npub" (avoids Option overhead)
839pub const NO_NPUB: u16 = u16::MAX;
840
841impl NpubInterner {
842    pub fn new() -> Self {
843        Self {
844            npubs: Vec::new(),
845            sorted: Vec::new(),
846        }
847    }
848
849    /// Pre-allocate capacity for expected number of unique npubs
850    pub fn with_capacity(capacity: usize) -> Self {
851        Self {
852            npubs: Vec::with_capacity(capacity),
853            sorted: Vec::with_capacity(capacity),
854        }
855    }
856
857    /// Intern an npub string, returning its stable index.
858    ///
859    /// If the npub already exists, returns the existing index.
860    /// If new, stores it and returns a new index.
861    pub fn intern(&mut self, npub: &str) -> u16 {
862        // Binary search in sorted order
863        let result = self.sorted.binary_search_by(|&idx| {
864            self.npubs[idx as usize].as_str().cmp(npub)
865        });
866
867        match result {
868            Ok(pos) => self.sorted[pos], // Found existing
869            Err(insert_pos) => {
870                // u16::MAX is the NO_NPUB sentinel; at/past it new indices would alias the
871                // sentinel then wrap, misattributing authors — degrade to authorless instead.
872                if self.npubs.len() >= NO_NPUB as usize {
873                    return NO_NPUB;
874                }
875                // New npub - add to both vectors
876                let new_idx = self.npubs.len() as u16;
877                self.npubs.push(npub.to_string());
878                self.sorted.insert(insert_pos, new_idx);
879                new_idx
880            }
881        }
882    }
883
884    /// Intern an optional npub, returning NO_NPUB sentinel for None.
885    #[inline]
886    pub fn intern_opt(&mut self, npub: Option<&str>) -> u16 {
887        match npub {
888            Some(s) if !s.is_empty() => self.intern(s),
889            _ => NO_NPUB,
890        }
891    }
892
893    /// Look up an npub without inserting. Returns its handle if already interned.
894    pub fn lookup(&self, npub: &str) -> Option<u16> {
895        self.sorted.binary_search_by(|&idx| {
896            self.npubs[idx as usize].as_str().cmp(npub)
897        }).ok().map(|pos| self.sorted[pos])
898    }
899
900    /// Resolve an index back to the npub string.
901    ///
902    /// Returns None for NO_NPUB sentinel or out-of-bounds index.
903    #[inline]
904    pub fn resolve(&self, idx: u16) -> Option<&str> {
905        if idx == NO_NPUB {
906            return None;
907        }
908        self.npubs.get(idx as usize).map(|s| s.as_str())
909    }
910
911    /// Number of unique npubs stored
912    #[inline]
913    pub fn len(&self) -> usize {
914        self.npubs.len()
915    }
916
917    #[inline]
918    pub fn is_empty(&self) -> bool {
919        self.npubs.is_empty()
920    }
921
922    /// Total memory used by the interner (approximate)
923    pub fn memory_usage(&self) -> usize {
924        std::mem::size_of::<Self>()
925            + self.npubs.capacity() * std::mem::size_of::<String>()
926            + self.npubs.iter().map(|s| s.capacity()).sum::<usize>()
927            + self.sorted.capacity() * std::mem::size_of::<u16>()
928    }
929}
930
931// ============================================================================
932// Compact Message
933// ============================================================================
934
935/// Memory-efficient message with binary IDs and interned npubs.
936///
937/// Compared to the regular `Message` struct:
938/// - IDs use `[u8; 32]` instead of hex String (saves ~56 bytes each)
939/// - npubs use u16 index into interner (saves ~85 bytes each)
940/// - Booleans packed into MessageFlags (saves ~24 bytes + 2 for replied_to_has_attachment)
941/// - Boxed optional IDs (replied_to, wrapper_id) save ~40 bytes when None
942/// - Compact timestamp (u32 seconds since 2020) saves 4 bytes
943/// - TinyVec for attachments/reactions (8 bytes vs 24 = saves 32 bytes)
944/// - Box<str> for content (8 bytes vs 24 = saves 16 bytes)
945/// - Total savings: ~350+ bytes per message
946#[derive(Clone, Debug)]
947pub struct CompactMessage {
948    /// Message ID as binary (64 hex chars -> 32 bytes)
949    pub id: [u8; 32],
950    /// Timestamp in milliseconds (full precision for sub-second ordering)
951    pub at: u64,
952    /// Packed boolean flags (mine, pending, failed, replied_to_has_attachment)
953    pub flags: MessageFlags,
954    /// Index into NpubInterner for sender's npub (NO_NPUB if none)
955    pub npub_idx: u16,
956    /// Replied-to message ID (boxed - None for ~70% of messages saves 24 bytes)
957    pub replied_to: Option<Box<[u8; 32]>>,
958    /// Index into NpubInterner for replied-to author (NO_NPUB if none)
959    pub replied_to_npub_idx: u16,
960    /// Wrapper event ID for gift-wrapped messages (boxed - saves 25 bytes when None)
961    pub wrapper_id: Option<Box<[u8; 32]>>,
962
963    // Variable-length fields - optimized for memory
964    /// Message content (Box<str> = 16 bytes vs String's 24 bytes)
965    pub content: Box<str>,
966    /// Content of replied-to message
967    pub replied_to_content: Option<Box<str>>,
968    /// File attachments (CompactAttachment = ~120 bytes vs Attachment's ~320 bytes)
969    pub attachments: TinyVec<CompactAttachment>,
970    /// Emoji reactions (CompactReaction = ~82 bytes vs Reaction's ~292 bytes)
971    pub reactions: TinyVec<CompactReaction>,
972    /// Edit history - boxed since <1% of messages are edited (saves 16 bytes inline)
973    #[allow(clippy::box_collection)]
974    pub edit_history: Option<Box<Vec<EditEntry>>>,
975    /// Link preview metadata - boxed since ~216 bytes but rare (saves ~208 bytes)
976    pub preview_metadata: Option<Box<SiteMetadata>>,
977    /// NIP-30 emoji tags travelling with this rumor — boxed because the
978    /// vast majority of messages have none, so the cold path stays cheap.
979    #[allow(clippy::box_collection)]
980    pub emoji_tags: Option<Box<Vec<crate::types::EmojiTag>>>,
981}
982
983impl CompactMessage {
984    /// Check if this message has a replied-to reference
985    #[inline]
986    pub fn has_reply(&self) -> bool {
987        self.replied_to.is_some()
988    }
989
990    /// Check if this message has been edited
991    #[inline]
992    pub fn is_edited(&self) -> bool {
993        self.edit_history.is_some()
994    }
995
996    /// Get the message ID as a string (hex for event IDs, "pending-..." for pending)
997    #[inline]
998    pub fn id_hex(&self) -> String {
999        decode_message_id(&self.id)
1000    }
1001
1002    /// Get the replied-to ID as a hex string, or empty if none
1003    #[inline]
1004    pub fn replied_to_hex(&self) -> String {
1005        match &self.replied_to {
1006            Some(id) => bytes_to_hex_32(id),
1007            None => String::new(),
1008        }
1009    }
1010
1011    /// Get wrapper ID as hex string if present
1012    #[inline]
1013    pub fn wrapper_id_hex(&self) -> Option<String> {
1014        self.wrapper_id.as_ref().map(|id| bytes_to_hex_32(id))
1015    }
1016
1017    /// Get timestamp as milliseconds (for compatibility with frontend)
1018    #[inline]
1019    pub fn timestamp_ms(&self) -> u64 {
1020        timestamp_from_compact(self.at)
1021    }
1022
1023    /// Apply an edit to this message.
1024    ///
1025    /// `emoji_tags` are the NIP-30 custom-emoji tags resolved from the new
1026    /// content; they're adopted only when this edit is the newest revision so
1027    /// an out-of-order older edit can't clobber the live content's emoji.
1028    pub fn apply_edit(&mut self, new_content: String, edited_at: u64, emoji_tags: Vec<crate::types::EmojiTag>) {
1029        // Initialize edit history with original content if not present
1030        if self.edit_history.is_none() {
1031            self.edit_history = Some(Box::new(vec![EditEntry {
1032                content: self.content.to_string(),
1033                edited_at: self.timestamp_ms(), // Convert compact to ms
1034            }]));
1035        }
1036
1037        let mut is_latest = true;
1038        if let Some(ref mut history) = self.edit_history {
1039            // Deduplicate: skip if we already have this edit
1040            if history.iter().any(|e| e.edited_at == edited_at) {
1041                return;
1042            }
1043
1044            // Add new edit to history
1045            history.push(EditEntry {
1046                content: new_content.clone(),
1047                edited_at,
1048            });
1049
1050            // Sort by timestamp
1051            history.sort_by_key(|e| e.edited_at);
1052            is_latest = history.last().map(|e| e.edited_at == edited_at).unwrap_or(true);
1053        }
1054
1055        // Only the newest revision drives the visible content + emoji.
1056        if is_latest {
1057            self.content = new_content.into_boxed_str();
1058            self.emoji_tags = if emoji_tags.is_empty() { None } else { Some(Box::new(emoji_tags)) };
1059        }
1060    }
1061
1062    /// Get replied_to_has_attachment from flags
1063    #[inline]
1064    pub fn replied_to_has_attachment(&self) -> Option<bool> {
1065        self.flags.replied_to_has_attachment()
1066    }
1067
1068    /// Add a reaction to this message
1069    /// Note: Since TinyVec is immutable, this rebuilds the entire reactions list
1070    pub fn add_reaction(&mut self, reaction: Reaction, interner: &mut NpubInterner) -> bool {
1071        // Convert to binary ID for comparison
1072        let reaction_id = hex_to_bytes_32(&reaction.id);
1073
1074        // Check if already exists
1075        if self.reactions.iter().any(|r| r.id == reaction_id) {
1076            return false;
1077        }
1078
1079        // Convert to compact and rebuild
1080        let compact = CompactReaction::from_reaction_owned(reaction, interner);
1081        let mut reactions = self.reactions.to_vec();
1082        reactions.push(compact);
1083        self.reactions = TinyVec::from_vec(reactions);
1084        true
1085    }
1086
1087    /// Remove a reaction by its hex event id. Returns true if one was removed.
1088    pub fn remove_reaction(&mut self, reaction_id: &str) -> bool {
1089        let target = hex_to_bytes_32(reaction_id);
1090        if !self.reactions.iter().any(|r| r.id == target) {
1091            return false;
1092        }
1093        let mut reactions = self.reactions.to_vec();
1094        reactions.retain(|r| r.id != target);
1095        self.reactions = TinyVec::from_vec(reactions);
1096        true
1097    }
1098
1099    // Flag accessors for compatibility
1100    #[inline]
1101    pub fn is_mine(&self) -> bool { self.flags.is_mine() }
1102    #[inline]
1103    pub fn is_pending(&self) -> bool { self.flags.is_pending() }
1104    #[inline]
1105    pub fn is_failed(&self) -> bool { self.flags.is_failed() }
1106
1107    // Flag setters
1108    #[inline]
1109    pub fn set_pending(&mut self, value: bool) { self.flags.set_pending(value); }
1110    #[inline]
1111    pub fn set_failed(&mut self, value: bool) { self.flags.set_failed(value); }
1112    #[inline]
1113    pub fn set_mine(&mut self, value: bool) { self.flags.set_mine(value); }
1114}
1115
1116// ============================================================================
1117// Compact Message Vec with Binary Search
1118// ============================================================================
1119
1120/// Sorted message storage with O(log n) lookup by ID.
1121///
1122/// Messages are stored sorted by timestamp. A separate index provides
1123/// O(log n) lookup by message ID using binary search.
1124#[derive(Clone, Debug, Default)]
1125pub struct CompactMessageVec {
1126    /// Messages sorted by timestamp (ascending)
1127    messages: Vec<CompactMessage>,
1128    /// Index for ID lookup: (id, position in messages), sorted by id
1129    id_index: Vec<([u8; 32], u32)>,
1130}
1131
1132impl CompactMessageVec {
1133    pub fn new() -> Self {
1134        Self {
1135            messages: Vec::new(),
1136            id_index: Vec::new(),
1137        }
1138    }
1139
1140    pub fn with_capacity(capacity: usize) -> Self {
1141        Self {
1142            messages: Vec::with_capacity(capacity),
1143            id_index: Vec::with_capacity(capacity),
1144        }
1145    }
1146
1147    /// Number of messages
1148    #[inline]
1149    pub fn len(&self) -> usize {
1150        self.messages.len()
1151    }
1152
1153    #[inline]
1154    pub fn is_empty(&self) -> bool {
1155        self.messages.is_empty()
1156    }
1157
1158    /// Get all messages (sorted by timestamp)
1159    #[inline]
1160    pub fn messages(&self) -> &[CompactMessage] {
1161        &self.messages
1162    }
1163
1164    /// Get a mutable reference to all messages
1165    #[inline]
1166    pub fn messages_mut(&mut self) -> &mut Vec<CompactMessage> {
1167        &mut self.messages
1168    }
1169
1170    /// Iterate over messages (supports .rev())
1171    #[inline]
1172    pub fn iter(&self) -> std::slice::Iter<'_, CompactMessage> {
1173        self.messages.iter()
1174    }
1175
1176    /// Iterate over messages mutably
1177    #[inline]
1178    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, CompactMessage> {
1179        self.messages.iter_mut()
1180    }
1181
1182    /// Get the last message
1183    #[inline]
1184    pub fn last(&self) -> Option<&CompactMessage> {
1185        self.messages.last()
1186    }
1187
1188    /// Get last message timestamp (in milliseconds)
1189    #[inline]
1190    pub fn last_timestamp(&self) -> Option<u64> {
1191        self.messages.last().map(|m| timestamp_from_compact(m.at))
1192    }
1193
1194    /// Get the first message
1195    #[inline]
1196    pub fn first(&self) -> Option<&CompactMessage> {
1197        self.messages.first()
1198    }
1199
1200    /// Find a message by ID using binary search - O(log n)
1201    pub fn find_by_id(&self, id: &[u8; 32]) -> Option<&CompactMessage> {
1202        let pos = self.id_index
1203            .binary_search_by(|(idx_id, _)| idx_id.cmp(id))
1204            .ok()?;
1205        let msg_pos = self.id_index[pos].1 as usize;
1206        self.messages.get(msg_pos)
1207    }
1208
1209    /// Find a message by ID (mutable) - O(log n)
1210    pub fn find_by_id_mut(&mut self, id: &[u8; 32]) -> Option<&mut CompactMessage> {
1211        let pos = self.id_index
1212            .binary_search_by(|(idx_id, _)| idx_id.cmp(id))
1213            .ok()?;
1214        let msg_pos = self.id_index[pos].1 as usize;
1215        self.messages.get_mut(msg_pos)
1216    }
1217
1218    /// Find a message by ID string (hex or pending) - O(log n)
1219    pub fn find_by_hex_id(&self, id_str: &str) -> Option<&CompactMessage> {
1220        if id_str.is_empty() {
1221            return None;
1222        }
1223        let id = encode_message_id(id_str);
1224        self.find_by_id(&id)
1225    }
1226
1227    /// Find a message by ID string (mutable) - O(log n)
1228    pub fn find_by_hex_id_mut(&mut self, id_str: &str) -> Option<&mut CompactMessage> {
1229        if id_str.is_empty() {
1230            return None;
1231        }
1232        let id = encode_message_id(id_str);
1233        self.find_by_id_mut(&id)
1234    }
1235
1236    /// Check if a message with the given ID exists - O(log n)
1237    pub fn contains_id(&self, id: &[u8; 32]) -> bool {
1238        self.id_index
1239            .binary_search_by(|(idx_id, _)| idx_id.cmp(id))
1240            .is_ok()
1241    }
1242
1243    /// Remove a message by hex ID string. Returns true if removed.
1244    pub fn remove_by_hex_id(&mut self, id_str: &str) -> bool {
1245        if id_str.is_empty() {
1246            return false;
1247        }
1248        let id = encode_message_id(id_str);
1249        // Find position in id_index
1250        let idx_pos = match self.id_index.binary_search_by(|(idx_id, _)| idx_id.cmp(&id)) {
1251            Ok(pos) => pos,
1252            Err(_) => return false,
1253        };
1254        let msg_pos = self.id_index[idx_pos].1 as usize;
1255        // Remove from messages vec
1256        self.messages.remove(msg_pos);
1257        // Rebuild index since positions shifted
1258        self.rebuild_index();
1259        true
1260    }
1261
1262    /// Check if a message with the given ID string exists - O(log n)
1263    pub fn contains_hex_id(&self, id_str: &str) -> bool {
1264        if id_str.is_empty() {
1265            return false;
1266        }
1267        let id = encode_message_id(id_str);
1268        self.contains_id(&id)
1269    }
1270
1271    /// Insert a message, maintaining sort order by timestamp.
1272    ///
1273    /// Returns true if the message was added, false if duplicate ID.
1274    ///
1275    /// **Performance**: O(log n) for append (common case), O(n) for out-of-order insert.
1276    pub fn insert(&mut self, msg: CompactMessage) -> bool {
1277        // Check for duplicate ID - O(log n)
1278        if self.contains_id(&msg.id) {
1279            return false;
1280        }
1281
1282        let msg_id = msg.id;
1283
1284        // Fast path: append if message is newer than or equal to last (common case)
1285        // This is O(log n) for the index insert only
1286        if self.messages.last().is_none_or(|last| msg.at >= last.at) {
1287            let msg_pos = self.messages.len() as u32;
1288            self.messages.push(msg);
1289
1290            // Insert into id_index (maintain sorted order by ID) - O(log n) search + O(n) shift
1291            // But the shift is typically small since IDs are random/sequential
1292            let idx_pos = self.id_index
1293                .binary_search_by(|(id, _)| id.cmp(&msg_id))
1294                .unwrap_err();
1295            self.id_index.insert(idx_pos, (msg_id, msg_pos));
1296
1297            return true;
1298        }
1299
1300        // Slow path: out-of-order insert (rare for real-time chat)
1301        // Find insertion position by timestamp
1302        let msg_pos = match self.messages.binary_search_by(|m| m.at.cmp(&msg.at)) {
1303            Ok(pos) => pos,
1304            Err(pos) => pos,
1305        };
1306
1307        // Update id_index positions for messages that will shift - O(n)
1308        for (_, pos) in &mut self.id_index {
1309            if *pos >= msg_pos as u32 {
1310                *pos += 1;
1311            }
1312        }
1313
1314        // Insert into messages - O(n)
1315        self.messages.insert(msg_pos, msg);
1316
1317        // Insert into id_index - O(n)
1318        let idx_pos = self.id_index
1319            .binary_search_by(|(id, _)| id.cmp(&msg_id))
1320            .unwrap_err();
1321        self.id_index.insert(idx_pos, (msg_id, msg_pos as u32));
1322
1323        true
1324    }
1325
1326    /// Rebuild the ID index (call after bulk modifications)
1327    pub fn rebuild_index(&mut self) {
1328        self.id_index.clear();
1329        self.id_index.reserve(self.messages.len());
1330        for (pos, msg) in self.messages.iter().enumerate() {
1331            self.id_index.push((msg.id, pos as u32));
1332        }
1333        self.id_index.sort_by(|(a, _), (b, _)| a.cmp(b));
1334    }
1335
1336    /// Batch insert messages - optimized for different scenarios.
1337    ///
1338    /// Returns the number of messages actually added (excludes duplicates).
1339    ///
1340    /// **Performance**:
1341    /// - Append case (newer msgs): O(k log n) where k = new messages
1342    /// - Prepend case (older msgs): O(k log n + k)
1343    /// - Mixed: O(n log n) full sort
1344    pub fn insert_batch(&mut self, messages: impl IntoIterator<Item = CompactMessage>) -> usize {
1345        let messages: Vec<_> = messages.into_iter().collect();
1346        if messages.is_empty() {
1347            return 0;
1348        }
1349
1350        // Quick dedup check using the index
1351        let mut to_add: Vec<CompactMessage> = Vec::with_capacity(messages.len());
1352        for msg in messages {
1353            if !self.contains_id(&msg.id) {
1354                to_add.push(msg);
1355            }
1356        }
1357
1358        if to_add.is_empty() {
1359            return 0;
1360        }
1361
1362        let added = to_add.len();
1363
1364        // Determine the insertion strategy based on timestamps
1365        let our_first = self.messages.first().map(|m| m.at);
1366        let our_last = self.messages.last().map(|m| m.at);
1367        let their_min = to_add.iter().map(|m| m.at).min().unwrap();
1368        let their_max = to_add.iter().map(|m| m.at).max().unwrap();
1369
1370        if self.messages.is_empty() {
1371            // Empty vec - just add and sort
1372            self.messages = to_add;
1373            self.messages.sort_by_key(|m| m.at);
1374            self.rebuild_index();
1375        } else if their_min >= our_last.unwrap() {
1376            // All new messages are NEWER - append path (common for real-time)
1377            to_add.sort_by_key(|m| m.at);
1378            let base_pos = self.messages.len() as u32;
1379            for (i, msg) in to_add.into_iter().enumerate() {
1380                let msg_id = msg.id;
1381                self.messages.push(msg);
1382                // Insert into index
1383                let idx_pos = self.id_index
1384                    .binary_search_by(|(id, _)| id.cmp(&msg_id))
1385                    .unwrap_err();
1386                self.id_index.insert(idx_pos, (msg_id, base_pos + i as u32));
1387            }
1388        } else if their_max <= our_first.unwrap() {
1389            // All new messages are OLDER - prepend path (common for pagination)
1390            to_add.sort_by_key(|m| m.at);
1391            let prepend_count = to_add.len();
1392
1393            // Shift all existing index positions
1394            for (_, pos) in &mut self.id_index {
1395                *pos += prepend_count as u32;
1396            }
1397
1398            // Build new index entries (already sorted by construction since to_add is sorted by timestamp)
1399            let mut new_index_entries: Vec<_> = to_add.iter()
1400                .enumerate()
1401                .map(|(i, msg)| (msg.id, i as u32))
1402                .collect();
1403            new_index_entries.sort_by(|(a, _), (b, _)| a.cmp(b));
1404
1405            // Merge sorted index entries in O(n + k) instead of O(k * n)
1406            let old_index = std::mem::take(&mut self.id_index);
1407            self.id_index.reserve(old_index.len() + new_index_entries.len());
1408
1409            let mut old_iter = old_index.into_iter().peekable();
1410            let mut new_iter = new_index_entries.into_iter().peekable();
1411
1412            while old_iter.peek().is_some() || new_iter.peek().is_some() {
1413                match (old_iter.peek(), new_iter.peek()) {
1414                    (Some((old_id, _)), Some((new_id, _))) => {
1415                        if old_id < new_id {
1416                            self.id_index.push(old_iter.next().unwrap());
1417                        } else {
1418                            self.id_index.push(new_iter.next().unwrap());
1419                        }
1420                    }
1421                    (Some(_), None) => self.id_index.push(old_iter.next().unwrap()),
1422                    (None, Some(_)) => self.id_index.push(new_iter.next().unwrap()),
1423                    (None, None) => break,
1424                }
1425            }
1426
1427            // Prepend messages
1428            let mut new_messages = to_add;
1429            new_messages.append(&mut self.messages);
1430            self.messages = new_messages;
1431        } else {
1432            // Mixed timestamps - fall back to full sort
1433            self.messages.extend(to_add);
1434            self.messages.sort_by_key(|m| m.at);
1435            self.rebuild_index();
1436        }
1437
1438        added
1439    }
1440
1441    /// Total memory used (approximate)
1442    pub fn memory_usage(&self) -> usize {
1443        std::mem::size_of::<Self>()
1444            + self.messages.capacity() * std::mem::size_of::<CompactMessage>()
1445            + self.id_index.capacity() * std::mem::size_of::<([u8; 32], u32)>()
1446            // Note: doesn't include heap allocations inside CompactMessage
1447    }
1448
1449    /// Drain messages from a range (rebuilds index after)
1450    pub fn drain(&mut self, range: std::ops::Range<usize>) -> std::vec::Drain<'_, CompactMessage> {
1451        let drain = self.messages.drain(range);
1452        // Note: caller should call rebuild_index() after consuming the drain
1453        drain
1454    }
1455
1456    /// Sort messages by a key (rebuilds index after)
1457    pub fn sort_by_key<K, F>(&mut self, f: F)
1458    where
1459        F: FnMut(&CompactMessage) -> K,
1460        K: Ord,
1461    {
1462        self.messages.sort_by_key(f);
1463        self.rebuild_index();
1464    }
1465
1466    /// Clear all messages
1467    pub fn clear(&mut self) {
1468        self.messages.clear();
1469        self.id_index.clear();
1470    }
1471}
1472
1473// ============================================================================
1474// Conversion from/to Message
1475// ============================================================================
1476
1477use crate::types::Message;
1478
1479impl CompactMessage {
1480    /// Convert from a regular Message (borrowed), interning npubs
1481    pub fn from_message(msg: &Message, interner: &mut NpubInterner) -> Self {
1482        Self {
1483            id: encode_message_id(&msg.id),
1484            at: timestamp_to_compact(msg.at),
1485            flags: MessageFlags::from_all(msg.mine, msg.pending, msg.failed, msg.replied_to_has_attachment),
1486            npub_idx: interner.intern_opt(msg.npub.as_deref()),
1487            // Box replied_to only when present (saves 24 bytes when None)
1488            replied_to: if msg.replied_to.is_empty() {
1489                None
1490            } else {
1491                Some(Box::new(hex_to_bytes_32(&msg.replied_to)))
1492            },
1493            replied_to_npub_idx: interner.intern_opt(msg.replied_to_npub.as_deref()),
1494            // Box wrapper_id (saves 25 bytes when None)
1495            wrapper_id: msg.wrapper_event_id.as_ref().map(|s| Box::new(hex_to_bytes_32(s))),
1496            // Box<str> for content (saves 8 bytes per field)
1497            content: msg.content.clone().into_boxed_str(),
1498            replied_to_content: msg.replied_to_content.as_ref().map(|s| s.clone().into_boxed_str()),
1499            // Convert attachments to compact format
1500            attachments: TinyVec::from_vec(
1501                msg.attachments.iter()
1502                    .map(CompactAttachment::from_attachment)
1503                    .collect()
1504            ),
1505            // Convert reactions to compact format
1506            reactions: TinyVec::from_vec(
1507                msg.reactions.iter()
1508                    .map(|r| CompactReaction::from_reaction(r, interner))
1509                    .collect()
1510            ),
1511            // Box rare fields to save inline space
1512            edit_history: msg.edit_history.clone().map(Box::new),
1513            preview_metadata: msg.preview_metadata.clone().map(Box::new),
1514            emoji_tags: if msg.emoji_tags.is_empty() {
1515                None
1516            } else {
1517                Some(Box::new(msg.emoji_tags.clone()))
1518            },
1519        }
1520    }
1521
1522    /// Convert from a regular Message (owned) - ZERO-COPY for strings!
1523    ///
1524    /// Takes ownership of the Message and moves strings directly.
1525    /// Use this when you don't need the original Message anymore.
1526    pub fn from_message_owned(msg: Message, interner: &mut NpubInterner) -> Self {
1527        Self {
1528            id: encode_message_id(&msg.id),
1529            at: timestamp_to_compact(msg.at),
1530            flags: MessageFlags::from_all(msg.mine, msg.pending, msg.failed, msg.replied_to_has_attachment),
1531            npub_idx: interner.intern_opt(msg.npub.as_deref()),
1532            // Box replied_to only when present (saves 24 bytes when None)
1533            replied_to: if msg.replied_to.is_empty() {
1534                None
1535            } else {
1536                Some(Box::new(hex_to_bytes_32(&msg.replied_to)))
1537            },
1538            replied_to_npub_idx: interner.intern_opt(msg.replied_to_npub.as_deref()),
1539            // Box wrapper_id (saves 25 bytes when None)
1540            wrapper_id: msg.wrapper_event_id.as_ref().map(|s| Box::new(hex_to_bytes_32(s))),
1541            // Zero-copy: into_boxed_str() reuses the String's buffer!
1542            content: msg.content.into_boxed_str(),
1543            replied_to_content: msg.replied_to_content.map(|s| s.into_boxed_str()),
1544            // Convert attachments to compact format (zero-copy where possible)
1545            attachments: TinyVec::from_vec(
1546                msg.attachments.into_iter()
1547                    .map(CompactAttachment::from_attachment_owned)
1548                    .collect()
1549            ),
1550            // Convert reactions to compact format (zero-copy for emoji string)
1551            reactions: TinyVec::from_vec(
1552                msg.reactions.into_iter()
1553                    .map(|r| CompactReaction::from_reaction_owned(r, interner))
1554                    .collect()
1555            ),
1556            // Box rare fields to save inline space
1557            edit_history: msg.edit_history.map(Box::new),
1558            preview_metadata: msg.preview_metadata.map(Box::new),
1559            emoji_tags: if msg.emoji_tags.is_empty() {
1560                None
1561            } else {
1562                Some(Box::new(msg.emoji_tags))
1563            },
1564        }
1565    }
1566
1567    /// Convert back to a regular Message, resolving npubs from interner
1568    pub fn to_message(&self, interner: &NpubInterner) -> Message {
1569        Message {
1570            id: self.id_hex(),
1571            at: self.timestamp_ms(), // Convert compact back to ms
1572            mine: self.flags.is_mine(),
1573            pending: self.flags.is_pending(),
1574            failed: self.flags.is_failed(),
1575            edited: self.is_edited(),
1576            npub: interner.resolve(self.npub_idx).map(|s| s.to_string()),
1577            replied_to: self.replied_to_hex(),
1578            replied_to_content: self.replied_to_content.as_ref().map(|s| s.to_string()),
1579            replied_to_npub: interner.resolve(self.replied_to_npub_idx).map(|s| s.to_string()),
1580            replied_to_has_attachment: self.flags.replied_to_has_attachment(),
1581            // Re-resolved per get_message_views / populate_reply_context; the compact
1582            // form keeps only the bool, so this stays None on the RAM path.
1583            replied_to_attachment_extension: None,
1584            wrapper_event_id: self.wrapper_id_hex(),
1585            content: self.content.to_string(),
1586            // Convert compact attachments back to regular Attachment
1587            attachments: self.attachments.iter()
1588                .map(|a| a.to_attachment())
1589                .collect(),
1590            // Convert compact reactions back to regular Reaction
1591            reactions: self.reactions.iter()
1592                .map(|r| r.to_reaction(interner))
1593                .collect(),
1594            // Unbox rare fields
1595            edit_history: self.edit_history.as_ref().map(|b| (**b).clone()),
1596            preview_metadata: self.preview_metadata.as_ref().map(|b| (**b).clone()),
1597            emoji_tags: self.emoji_tags.as_ref().map(|b| (**b).clone()).unwrap_or_default(),
1598        }
1599    }
1600}
1601
1602#[cfg(test)]
1603mod tests {
1604    use super::*;
1605
1606    // A real event id that happens to start with the pending marker byte must
1607    // round-trip EXACTLY — the marker alone once misread 1 in 256 real ids as
1608    // phantom "pending-…" strings, wedging read markers among other consumers.
1609    #[test]
1610    fn real_id_starting_with_marker_byte_roundtrips() {
1611        let id = "01b95c05179c6d6abbc60b9a35198fdee6e0ce5b80a0b7ac8d465d81d038a73d";
1612        let encoded = encode_message_id(id);
1613        assert_eq!(decode_message_id(&encoded), id);
1614    }
1615
1616    #[test]
1617    fn pending_id_roundtrips() {
1618        let id = "pending-306878031314";
1619        let encoded = encode_message_id(id);
1620        assert_eq!(encoded[0], PENDING_ID_MARKER);
1621        assert_eq!(decode_message_id(&encoded), id);
1622    }
1623
1624    #[test]
1625    fn marker_byte_without_sentinel_decodes_as_hex() {
1626        // Marker byte + arbitrary tail (no sentinel) is a real id, not pending.
1627        let mut bytes = [0xABu8; 32];
1628        bytes[0] = PENDING_ID_MARKER;
1629        let decoded = decode_message_id(&bytes);
1630        assert!(!decoded.starts_with("pending-"));
1631        assert_eq!(decoded.len(), 64);
1632    }
1633
1634    #[test]
1635    fn test_message_flags() {
1636        let mut flags = MessageFlags::NONE;
1637        assert!(!flags.is_mine());
1638        assert!(!flags.is_pending());
1639        assert!(!flags.is_failed());
1640
1641        flags.set_mine(true);
1642        assert!(flags.is_mine());
1643
1644        flags.set_pending(true);
1645        assert!(flags.is_pending());
1646        assert!(flags.is_mine()); // Still set
1647
1648        flags.set_mine(false);
1649        assert!(!flags.is_mine());
1650        assert!(flags.is_pending()); // Still set
1651    }
1652
1653    #[test]
1654    fn test_npub_interner() {
1655        let mut interner = NpubInterner::new();
1656
1657        let idx1 = interner.intern("npub1alice");
1658        let idx2 = interner.intern("npub1bob");
1659        let idx3 = interner.intern("npub1alice"); // Duplicate
1660
1661        assert_eq!(idx1, idx3); // Same string = same index
1662        assert_ne!(idx1, idx2);
1663
1664        assert_eq!(interner.resolve(idx1), Some("npub1alice"));
1665        assert_eq!(interner.resolve(idx2), Some("npub1bob"));
1666        assert_eq!(interner.resolve(NO_NPUB), None);
1667    }
1668
1669    #[test]
1670    fn test_compact_message_vec_insert_and_find() {
1671        let mut vec = CompactMessageVec::new();
1672        let mut interner = NpubInterner::new();
1673
1674        let msg1 = CompactMessage {
1675            id: hex_to_bytes_32("0000000000000000000000000000000000000000000000000000000000000001"),
1676            at: 1000,
1677            flags: MessageFlags::NONE,
1678            npub_idx: interner.intern("npub1test"),
1679            replied_to: None,
1680            replied_to_npub_idx: NO_NPUB,
1681            wrapper_id: None,
1682            content: "First message".to_string().into_boxed_str(),
1683            replied_to_content: None,
1684            attachments: TinyVec::new(),
1685            reactions: TinyVec::new(),
1686            edit_history: None,
1687            preview_metadata: None,  // Boxed, but None = 8 bytes
1688            emoji_tags: None,
1689        };
1690
1691        let msg2 = CompactMessage {
1692            id: hex_to_bytes_32("0000000000000000000000000000000000000000000000000000000000000002"),
1693            at: 2000,
1694            flags: MessageFlags::MINE,
1695            npub_idx: interner.intern("npub1me"),
1696            replied_to: None,
1697            replied_to_npub_idx: NO_NPUB,
1698            wrapper_id: None,
1699            content: "Second message".to_string().into_boxed_str(),
1700            replied_to_content: None,
1701            attachments: TinyVec::new(),
1702            reactions: TinyVec::new(),
1703            edit_history: None,
1704            preview_metadata: None,  // Boxed, but None = 8 bytes
1705            emoji_tags: None,
1706        };
1707
1708        assert!(vec.insert(msg1));
1709        assert!(vec.insert(msg2));
1710        assert_eq!(vec.len(), 2);
1711
1712        // Find by ID
1713        let found = vec.find_by_hex_id("0000000000000000000000000000000000000000000000000000000000000001");
1714        assert!(found.is_some());
1715        assert_eq!(&*found.unwrap().content, "First message");
1716
1717        // Find non-existent
1718        let not_found = vec.find_by_hex_id("0000000000000000000000000000000000000000000000000000000000000099");
1719        assert!(not_found.is_none());
1720    }
1721
1722    #[test]
1723    fn test_duplicate_insert_rejected() {
1724        let mut vec = CompactMessageVec::new();
1725
1726        let msg = CompactMessage {
1727            id: hex_to_bytes_32("abcd000000000000000000000000000000000000000000000000000000000000"),
1728            at: 1000,
1729            flags: MessageFlags::NONE,
1730            npub_idx: NO_NPUB,
1731            replied_to: None,
1732            replied_to_npub_idx: NO_NPUB,
1733            wrapper_id: None,
1734            content: "Test".to_string().into_boxed_str(),
1735            replied_to_content: None,
1736            attachments: TinyVec::new(),
1737            reactions: TinyVec::new(),
1738            edit_history: None,
1739            preview_metadata: None,  // Boxed
1740            emoji_tags: None,
1741        };
1742
1743        assert!(vec.insert(msg.clone()));
1744        assert!(!vec.insert(msg)); // Duplicate rejected
1745        assert_eq!(vec.len(), 1);
1746    }
1747
1748    /// Comprehensive benchmark test for memory reduction and performance
1749    #[test]
1750    fn benchmark_compact_vs_message() {
1751        use std::time::Instant;
1752
1753        const NUM_MESSAGES: usize = 10_000;
1754        const NUM_UNIQUE_USERS: usize = 50; // Realistic chat scenario
1755
1756        println!("\n========================================");
1757        println!("  COMPACT MESSAGE BENCHMARK");
1758        println!("  {} messages, {} unique users", NUM_MESSAGES, NUM_UNIQUE_USERS);
1759        println!("========================================\n");
1760
1761        // Generate test data
1762        let users: Vec<String> = (0..NUM_UNIQUE_USERS)
1763            .map(|i| format!("npub1{:0>62}", i))
1764            .collect();
1765
1766        // Create regular Messages
1767        let messages: Vec<Message> = (0..NUM_MESSAGES)
1768            .map(|i| {
1769                let user_idx = i % NUM_UNIQUE_USERS;
1770                Message {
1771                    id: format!("{:0>64x}", i),
1772                    at: 1700000000000 + (i as u64 * 1000),
1773                    mine: user_idx == 0,
1774                    pending: false,
1775                    failed: false,
1776                    edited: false,
1777                    npub: Some(users[user_idx].clone()),
1778                    replied_to: if i > 0 && i % 5 == 0 {
1779                        format!("{:0>64x}", i - 1)
1780                    } else {
1781                        String::new()
1782                    },
1783                    replied_to_content: if i > 0 && i % 5 == 0 {
1784                        Some("Previous message content".to_string())
1785                    } else {
1786                        None
1787                    },
1788                    replied_to_npub: if i > 0 && i % 5 == 0 {
1789                        Some(users[(i - 1) % NUM_UNIQUE_USERS].clone())
1790                    } else {
1791                        None
1792                    },
1793                    replied_to_has_attachment: None,
1794                    replied_to_attachment_extension: None,
1795                    wrapper_event_id: Some(format!("{:0>64x}", i + 1000000)),
1796                    content: format!("This is message number {} with some typical content length.", i),
1797                    attachments: vec![],
1798                    reactions: vec![],
1799                    edit_history: None,
1800                    preview_metadata: None,
1801                    emoji_tags: Vec::new(),
1802                }
1803            })
1804            .collect();
1805
1806        // ===== MEMORY COMPARISON =====
1807        println!("--- STRUCT SIZES ---");
1808        println!("  Message struct:        {} bytes", std::mem::size_of::<Message>());
1809        println!("  CompactMessage struct: {} bytes", std::mem::size_of::<CompactMessage>());
1810        println!("  Savings per struct:    {} bytes ({:.1}%)",
1811            std::mem::size_of::<Message>().saturating_sub(std::mem::size_of::<CompactMessage>()),
1812            (1.0 - std::mem::size_of::<CompactMessage>() as f64 / std::mem::size_of::<Message>() as f64) * 100.0
1813        );
1814        println!();
1815
1816        // Measure Message storage (simulating Vec<Message>)
1817        let msg_heap_estimate: usize = messages.iter().map(|m| {
1818            m.id.capacity()
1819                + m.npub.as_ref().map(|s| s.capacity()).unwrap_or(0)
1820                + m.replied_to.capacity()
1821                + m.replied_to_content.as_ref().map(|s| s.capacity()).unwrap_or(0)
1822                + m.replied_to_npub.as_ref().map(|s| s.capacity()).unwrap_or(0)
1823                + m.wrapper_event_id.as_ref().map(|s| s.capacity()).unwrap_or(0)
1824                + m.content.capacity()
1825        }).sum();
1826        let msg_total = messages.len() * std::mem::size_of::<Message>() + msg_heap_estimate;
1827
1828        // ===== CONVERSION + INSERT BENCHMARK =====
1829        println!("--- INSERT BENCHMARK ---");
1830
1831        // Test 1: Sequential inserts (simulates real-time message arrival)
1832        let mut interner = NpubInterner::with_capacity(NUM_UNIQUE_USERS);
1833        let mut compact_vec = CompactMessageVec::with_capacity(NUM_MESSAGES);
1834
1835        let insert_start = Instant::now();
1836        for msg in &messages {
1837            let compact = CompactMessage::from_message(msg, &mut interner);
1838            compact_vec.insert(compact);
1839        }
1840        let insert_elapsed = insert_start.elapsed();
1841
1842        println!("  Sequential insert (optimized append path):");
1843        println!("    {} messages in {:?}", NUM_MESSAGES, insert_elapsed);
1844        println!("    Rate: {:.0} msgs/sec", NUM_MESSAGES as f64 / insert_elapsed.as_secs_f64());
1845        println!("    Per message: {:.3} us ({} ns)",
1846            insert_elapsed.as_micros() as f64 / NUM_MESSAGES as f64,
1847            insert_elapsed.as_nanos() / NUM_MESSAGES as u128);
1848        println!();
1849
1850        // Test 2: Batch insert (simulates pagination/history loading)
1851        let mut interner2 = NpubInterner::with_capacity(NUM_UNIQUE_USERS);
1852        let mut compact_vec2 = CompactMessageVec::with_capacity(NUM_MESSAGES);
1853
1854        let batch_start = Instant::now();
1855        let compact_messages: Vec<_> = messages.iter()
1856            .map(|msg| CompactMessage::from_message(msg, &mut interner2))
1857            .collect();
1858        let batch_added = compact_vec2.insert_batch(compact_messages);
1859        let batch_elapsed = batch_start.elapsed();
1860
1861        println!("  Batch insert (pagination/history load):");
1862        println!("    {} messages in {:?}", batch_added, batch_elapsed);
1863        println!("    Rate: {:.0} msgs/sec", NUM_MESSAGES as f64 / batch_elapsed.as_secs_f64());
1864        println!("    Per message: {:.3} us ({} ns)",
1865            batch_elapsed.as_micros() as f64 / NUM_MESSAGES as f64,
1866            batch_elapsed.as_nanos() / NUM_MESSAGES as u128);
1867        println!();
1868
1869        // ===== COMPACT MEMORY USAGE =====
1870        println!("--- MEMORY COMPARISON ---");
1871        let compact_heap_estimate: usize = compact_vec.iter().map(|m| {
1872            m.content.len()  // Box<str> has no capacity, just len
1873                + m.replied_to_content.as_ref().map(|s| s.len()).unwrap_or(0)
1874                + m.attachments.len() * std::mem::size_of::<Attachment>() + if m.attachments.is_empty() { 0 } else { 1 }
1875                + m.reactions.len() * std::mem::size_of::<Reaction>() + if m.reactions.is_empty() { 0 } else { 1 }
1876        }).sum();
1877        let compact_struct_mem = compact_vec.len() * std::mem::size_of::<CompactMessage>();
1878        let compact_index_mem = compact_vec.len() * std::mem::size_of::<([u8; 32], u32)>();
1879        let interner_mem = interner.memory_usage();
1880        let compact_total = compact_struct_mem + compact_heap_estimate + compact_index_mem + interner_mem;
1881
1882        println!("  Regular Message storage:");
1883        println!("    Struct memory:     {:>10} bytes", messages.len() * std::mem::size_of::<Message>());
1884        println!("    Heap (strings):    {:>10} bytes", msg_heap_estimate);
1885        println!("    TOTAL:             {:>10} bytes ({:.2} MB)", msg_total, msg_total as f64 / 1_000_000.0);
1886        println!();
1887        println!("  CompactMessage storage:");
1888        println!("    Struct memory:     {:>10} bytes", compact_struct_mem);
1889        println!("    Heap (strings):    {:>10} bytes", compact_heap_estimate);
1890        println!("    ID index:          {:>10} bytes", compact_index_mem);
1891        println!("    Interner:          {:>10} bytes ({} unique npubs)", interner_mem, interner.len());
1892        println!("    TOTAL:             {:>10} bytes ({:.2} MB)", compact_total, compact_total as f64 / 1_000_000.0);
1893        println!();
1894        println!("  SAVINGS: {} bytes ({:.1}%)",
1895            msg_total.saturating_sub(compact_total),
1896            (1.0 - compact_total as f64 / msg_total as f64) * 100.0
1897        );
1898        println!("  Per message: {} -> {} bytes (avg)",
1899            msg_total / NUM_MESSAGES,
1900            compact_total / NUM_MESSAGES
1901        );
1902        println!();
1903
1904        // ===== LOOKUP BENCHMARK =====
1905        println!("--- LOOKUP BENCHMARK ---");
1906
1907        // Generate random lookup IDs (mix of existing and non-existing)
1908        let lookup_ids: Vec<String> = (0..1000)
1909            .map(|i| format!("{:0>64x}", i * 10)) // Every 10th message
1910            .collect();
1911
1912        // Benchmark binary search lookup (CompactMessageVec)
1913        let lookup_start = Instant::now();
1914        let mut found_count = 0;
1915        for _ in 0..100 { // 100 iterations
1916            for id in &lookup_ids {
1917                if compact_vec.find_by_hex_id(id).is_some() {
1918                    found_count += 1;
1919                }
1920            }
1921        }
1922        let lookup_elapsed = lookup_start.elapsed();
1923        let total_lookups = 100 * lookup_ids.len();
1924
1925        println!("  Binary search (CompactMessageVec):");
1926        println!("    {} lookups in {:?}", total_lookups, lookup_elapsed);
1927        println!("    Rate: {:.0} lookups/sec", total_lookups as f64 / lookup_elapsed.as_secs_f64());
1928        println!("    Per lookup: {:.2} us", lookup_elapsed.as_micros() as f64 / total_lookups as f64);
1929        println!("    Found: {} / {}", found_count, total_lookups);
1930        println!();
1931
1932        // Benchmark linear search (simulating Vec<Message>)
1933        let linear_start = Instant::now();
1934        let mut linear_found = 0;
1935        for _ in 0..100 {
1936            for id in &lookup_ids {
1937                if messages.iter().find(|m| &m.id == id).is_some() {
1938                    linear_found += 1;
1939                }
1940            }
1941        }
1942        let linear_elapsed = linear_start.elapsed();
1943
1944        println!("  Linear search (Vec<Message>):");
1945        println!("    {} lookups in {:?}", total_lookups, linear_elapsed);
1946        println!("    Rate: {:.0} lookups/sec", total_lookups as f64 / linear_elapsed.as_secs_f64());
1947        println!("    Per lookup: {:.2} us", linear_elapsed.as_micros() as f64 / total_lookups as f64);
1948        println!();
1949
1950        let speedup = linear_elapsed.as_nanos() as f64 / lookup_elapsed.as_nanos() as f64;
1951        println!("  SPEEDUP: {:.1}x faster with binary search!", speedup);
1952        println!();
1953
1954        // ===== INTERNER EFFICIENCY =====
1955        println!("--- INTERNER EFFICIENCY ---");
1956        let npub_string_size = 63 + 1; // "npub1" + 58 chars + null
1957        let naive_npub_mem = NUM_MESSAGES * npub_string_size * 2; // npub + replied_to_npub
1958        let actual_npub_mem = interner_mem;
1959        println!("  Naive (every msg stores npubs): {} bytes", naive_npub_mem);
1960        println!("  Interned ({} unique):           {} bytes", interner.len(), actual_npub_mem);
1961        println!("  SAVINGS: {} bytes ({:.1}%)",
1962            naive_npub_mem.saturating_sub(actual_npub_mem),
1963            (1.0 - actual_npub_mem as f64 / naive_npub_mem as f64) * 100.0
1964        );
1965        println!();
1966
1967        println!("========================================");
1968        println!("  BENCHMARK COMPLETE");
1969        println!("========================================\n");
1970
1971        // Verify correctness
1972        assert_eq!(compact_vec.len(), NUM_MESSAGES);
1973        assert_eq!(interner.len(), NUM_UNIQUE_USERS);
1974        assert_eq!(found_count, linear_found);
1975    }
1976
1977    /// Benchmark: Profile lookup -- linear scan vs string binary search vs handle binary search
1978    ///
1979    /// Compares three approaches for finding a Profile in a Vec:
1980    /// 1. Linear scan with string equality (old -- O(n) x 63-byte strcmp, Profile.id was String)
1981    /// 2. Binary search by npub string (intermediate -- O(log n) x 63-byte strcmp)
1982    /// 3. Direct u16 handle binary search (current -- O(log n) x 2-byte int cmp, Profile.id is u16)
1983    #[test]
1984    fn benchmark_profile_lookup() {
1985        use std::time::Instant;
1986        use std::hint::black_box;
1987
1988        const NUM_PROFILES: usize = 60;
1989        const NUM_LOOKUPS: usize = 100_000;
1990
1991        println!("\n========================================");
1992        println!("  PROFILE LOOKUP BENCHMARK");
1993        println!("  {} profiles, {} lookups each method", NUM_PROFILES, NUM_LOOKUPS);
1994        println!("========================================\n");
1995
1996        // Generate realistic npubs (63 chars each: "npub1" + 58 hex-like chars)
1997        let npubs: Vec<String> = (0..NUM_PROFILES)
1998            .map(|i| format!("npub1{:0>58}", format!("{:x}", i * 7919 + 1000))) // spread out values
1999            .collect();
2000
2001        // --- Setup: Method 1 - Linear scan (old approach, simulating id: String) ---
2002        let old_ids: Vec<String> = npubs.iter().rev().cloned().collect(); // reversed = worst case
2003
2004        // --- Setup: Method 2 - String binary search (intermediate approach) ---
2005        let mut sorted_ids: Vec<String> = npubs.clone();
2006        sorted_ids.sort();
2007
2008        // --- Setup: Method 3 - Direct u16 handle lookup (current approach, id: u16) ---
2009        let mut interner = NpubInterner::new();
2010        let mut profiles: Vec<crate::profile::Profile> = npubs.iter().map(|npub| {
2011            let mut p = crate::profile::Profile::new();
2012            p.id = interner.intern(npub);
2013            p
2014        }).collect();
2015        profiles.sort_by(|a, b| a.id.cmp(&b.id));
2016
2017        // Build lookup targets: cycle through all profiles
2018        let lookup_targets: Vec<&str> = (0..NUM_LOOKUPS)
2019            .map(|i| npubs[i % NUM_PROFILES].as_str())
2020            .collect();
2021
2022        // Pre-resolve handles for method 3
2023        let handle_targets: Vec<u16> = lookup_targets.iter()
2024            .map(|&npub| interner.lookup(npub).unwrap())
2025            .collect();
2026
2027        // ===== BENCHMARK 1: Linear scan (old -- id: String) =====
2028        let start = Instant::now();
2029        let mut found = 0u64;
2030        for &target in &lookup_targets {
2031            if old_ids.iter().any(|id| id == target) {
2032                found += 1;
2033            }
2034        }
2035        let linear_elapsed = start.elapsed();
2036        assert_eq!(found, NUM_LOOKUPS as u64);
2037
2038        // ===== BENCHMARK 2: String binary search (intermediate) =====
2039        let start = Instant::now();
2040        found = 0;
2041        for &target in &lookup_targets {
2042            if sorted_ids.binary_search_by(|id| id.as_str().cmp(target)).is_ok() {
2043                found += 1;
2044            }
2045        }
2046        let string_bs_elapsed = start.elapsed();
2047        assert_eq!(found, NUM_LOOKUPS as u64);
2048
2049        // ===== BENCHMARK 3: Direct u16 handle lookup (current -- id: u16) =====
2050        let start = Instant::now();
2051        found = 0;
2052        for &handle in &handle_targets {
2053            if profiles.binary_search_by(|p| p.id.cmp(black_box(&handle))).is_ok() {
2054                found += 1;
2055            }
2056        }
2057        let direct_elapsed = start.elapsed();
2058        assert_eq!(found, NUM_LOOKUPS as u64);
2059
2060        // ===== RESULTS =====
2061        println!("--- LOOKUP METHODS ---");
2062        println!("  1. Linear scan (old id: String):");
2063        println!("     {:?} total, {:.0} ns/lookup",
2064            linear_elapsed,
2065            linear_elapsed.as_nanos() as f64 / NUM_LOOKUPS as f64);
2066        println!();
2067        println!("  2. String binary search (intermediate):");
2068        println!("     {:?} total, {:.0} ns/lookup",
2069            string_bs_elapsed,
2070            string_bs_elapsed.as_nanos() as f64 / NUM_LOOKUPS as f64);
2071        println!("     vs linear: {:.1}x faster",
2072            linear_elapsed.as_nanos() as f64 / string_bs_elapsed.as_nanos() as f64);
2073        println!();
2074        println!("  3. Direct u16 handle lookup (current id: u16):");
2075        println!("     {:?} total, {:.0} ns/lookup",
2076            direct_elapsed,
2077            direct_elapsed.as_nanos() as f64 / NUM_LOOKUPS as f64);
2078        println!("     vs linear: {:.1}x faster",
2079            linear_elapsed.as_nanos() as f64 / direct_elapsed.as_nanos() as f64);
2080        println!("     vs string BS: {:.1}x faster",
2081            string_bs_elapsed.as_nanos() as f64 / direct_elapsed.as_nanos() as f64);
2082        println!();
2083
2084        // ===== MEMORY COMPARISON =====
2085        println!("--- MEMORY PER PROFILE ---");
2086        println!("  Old (id: String):    ~87 bytes (24 String header + ~63 heap)");
2087        println!("  Current (id: u16):     2 bytes (inline)");
2088        println!("  Savings: ~85 bytes/profile, ~{} bytes for {} profiles",
2089            85 * NUM_PROFILES, NUM_PROFILES);
2090        println!("  Interner (shared):   {} bytes (shared with message system)",
2091            interner.memory_usage());
2092        println!();
2093
2094        println!("========================================");
2095        println!("  BENCHMARK COMPLETE");
2096        println!("========================================\n");
2097
2098        // Correctness: ensure all methods find the same profiles
2099        for npub in &npubs {
2100            assert!(old_ids.iter().any(|id| id == npub));
2101            assert!(sorted_ids.binary_search_by(|id| id.as_str().cmp(npub.as_str())).is_ok());
2102            let h = interner.lookup(npub).unwrap();
2103            assert!(profiles.binary_search_by(|p| p.id.cmp(&h)).is_ok());
2104        }
2105    }
2106
2107    // ========================================================================
2108    // Pending ID Encoding Tests
2109    // ========================================================================
2110
2111    #[test]
2112    fn pending_id_roundtrip_regular_hex() {
2113        let hex = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
2114        let encoded = encode_message_id(hex);
2115        let decoded = decode_message_id(&encoded);
2116        assert_eq!(decoded, hex, "regular hex ID should roundtrip exactly");
2117    }
2118
2119    #[test]
2120    fn pending_id_roundtrip_pending() {
2121        let id = "pending-1234567890123456789";
2122        let encoded = encode_message_id(id);
2123        let decoded = decode_message_id(&encoded);
2124        assert_eq!(decoded, id, "pending ID should roundtrip exactly");
2125    }
2126
2127    #[test]
2128    fn pending_id_zero_timestamp() {
2129        let id = "pending-0";
2130        let encoded = encode_message_id(id);
2131        assert_eq!(encoded[0], PENDING_ID_MARKER, "first byte should be marker");
2132        let decoded = decode_message_id(&encoded);
2133        assert_eq!(decoded, id, "pending-0 should roundtrip");
2134    }
2135
2136    #[test]
2137    fn pending_id_max_u128_timestamp() {
2138        let max = u128::MAX;
2139        let id = format!("pending-{}", max);
2140        let encoded = encode_message_id(&id);
2141        let decoded = decode_message_id(&encoded);
2142        assert_eq!(decoded, id, "pending with max u128 should roundtrip");
2143    }
2144
2145    #[test]
2146    fn pending_id_all_zero_hex() {
2147        let hex = "0000000000000000000000000000000000000000000000000000000000000000";
2148        let encoded = encode_message_id(hex);
2149        let decoded = decode_message_id(&encoded);
2150        assert_eq!(decoded, hex, "all-zero hex ID should roundtrip");
2151        // All-zero should NOT be detected as pending since byte 0 is 0x00, not 0x01
2152        assert_ne!(encoded[0], PENDING_ID_MARKER);
2153    }
2154
2155    #[test]
2156    fn pending_id_all_ff_hex() {
2157        let hex = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
2158        let encoded = encode_message_id(hex);
2159        let decoded = decode_message_id(&encoded);
2160        assert_eq!(decoded, hex, "all-ff hex ID should roundtrip");
2161        assert_eq!(encoded, [0xff; 32], "all-ff should decode to all 0xff bytes");
2162    }
2163
2164    #[test]
2165    fn pending_id_mixed_case_hex() {
2166        let lower = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
2167        let mixed = "ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789";
2168        let encoded_lower = encode_message_id(lower);
2169        let encoded_mixed = encode_message_id(mixed);
2170        assert_eq!(encoded_lower, encoded_mixed, "mixed case should produce same bytes as lowercase");
2171    }
2172
2173    #[test]
2174    fn pending_id_short_hex_partial_decode() {
2175        // SIMD hex_to_bytes_32 partially decodes short input (decodes what it can)
2176        let short = "abcdef";
2177        let encoded = encode_message_id(short);
2178        // Short input is decoded as far as possible, remaining bytes are zero
2179        assert_eq!(encoded[0..14], [0u8; 14], "leading bytes should be zero for short input");
2180    }
2181
2182    #[test]
2183    fn pending_id_marker_distinguishes_from_real_id() {
2184        // A real event ID starting with 0x01 must NOT be confused with pending:
2185        // 1 in 256 real ids begin with the marker byte, and misreading them
2186        // mangled read markers into phantom "pending-…" strings. Only the
2187        // marker + sentinel combination reads as pending.
2188        let hex = "0100000000000000000000000000000000000000000000000000000000000000";
2189        let encoded = encode_message_id(hex);
2190        let decoded = decode_message_id(&encoded);
2191        assert_eq!(decoded, hex, "marker-leading real id must roundtrip exactly");
2192    }
2193
2194    #[test]
2195    fn pending_id_large_timestamp() {
2196        let id = "pending-99999999999999999";
2197        let encoded = encode_message_id(&id);
2198        let decoded = decode_message_id(&encoded);
2199        assert_eq!(decoded, id, "large but valid timestamp should roundtrip");
2200    }
2201
2202    #[test]
2203    fn pending_id_invalid_timestamp_becomes_zero() {
2204        // If the timestamp part can't be parsed, it stays as all-zeros in bytes 1..17
2205        let id = "pending-notanumber";
2206        let encoded = encode_message_id(id);
2207        assert_eq!(encoded[0], PENDING_ID_MARKER);
2208        // Bytes 1..17 should all be 0
2209        assert_eq!(&encoded[1..17], &[0u8; 16]);
2210        let decoded = decode_message_id(&encoded);
2211        assert_eq!(decoded, "pending-0", "invalid timestamp parses as pending-0");
2212    }
2213
2214    // ========================================================================
2215    // Timestamp Tests
2216    // ========================================================================
2217
2218    #[test]
2219    fn timestamp_to_compact_and_back_roundtrip() {
2220        // A representative timestamp: 2024-01-15 12:00:00 UTC in milliseconds
2221        let ms: u64 = 1705320000000;
2222        let compact = timestamp_to_compact(ms);
2223        let restored = timestamp_from_compact(compact);
2224        // The sub-second part is lost (ms -> seconds -> ms), so restored is floored to seconds
2225        assert_eq!(restored / 1000, ms / 1000, "roundtrip should preserve seconds");
2226    }
2227
2228    #[test]
2229    fn secs_to_compact_and_back_roundtrip() {
2230        let secs: u64 = 1705320000; // 2024-01-15 12:00:00 UTC
2231        let compact = secs_to_compact(secs);
2232        let restored = secs_from_compact(compact);
2233        assert_eq!(restored, secs, "secs should roundtrip exactly");
2234    }
2235
2236    #[test]
2237    fn timestamp_zero_preservation() {
2238        // Zero is a sentinel for "never set" in secs_to_compact/secs_from_compact
2239        assert_eq!(secs_to_compact(0), 0, "zero secs should produce zero compact");
2240        assert_eq!(secs_from_compact(0), 0, "zero compact should produce zero secs");
2241    }
2242
2243    #[test]
2244    fn timestamp_epoch_boundary() {
2245        // Exactly 2020-01-01 00:00:00 UTC = EPOCH_2020_SECS
2246        let epoch_secs: u64 = 1577836800;
2247        let compact = secs_to_compact(epoch_secs);
2248        assert_eq!(compact, 0, "epoch boundary should map to compact 0");
2249        let restored = secs_from_compact(compact);
2250        // secs_from_compact(0) returns 0 (sentinel), not epoch
2251        assert_eq!(restored, 0, "compact 0 returns sentinel 0");
2252    }
2253
2254    #[test]
2255    fn timestamp_epoch_boundary_ms() {
2256        let epoch_ms: u64 = 1577836800000;
2257        let compact = timestamp_to_compact(epoch_ms);
2258        assert_eq!(compact, epoch_ms, "full u64 — identity function");
2259        let restored = timestamp_from_compact(compact);
2260        assert_eq!(restored, epoch_ms, "roundtrip preserves value");
2261    }
2262
2263    #[test]
2264    fn timestamp_current_time_roundtrip() {
2265        // Simulate a current-ish timestamp: 2026-03-27 in seconds
2266        let secs: u64 = 1774800000;
2267        let compact = secs_to_compact(secs);
2268        let restored = secs_from_compact(compact);
2269        assert_eq!(restored, secs, "current-era timestamp should roundtrip");
2270        assert!(compact > 0, "current time should be past epoch");
2271    }
2272
2273    #[test]
2274    fn timestamp_far_future_year_2100() {
2275        // 2100-01-01 00:00:00 UTC
2276        let secs: u64 = 4102444800;
2277        let compact = secs_to_compact(secs);
2278        let restored = secs_from_compact(compact);
2279        assert_eq!(restored, secs, "year 2100 should roundtrip");
2280        // Verify it fits in u32
2281        assert!(compact <= u32::MAX, "year 2100 should fit in u32");
2282    }
2283
2284    #[test]
2285    fn timestamp_pre_epoch_saturates_to_zero() {
2286        // A timestamp before 2020 epoch
2287        let secs: u64 = 1500000000; // ~2017
2288        let compact = secs_to_compact(secs);
2289        // saturating_sub means this becomes 0
2290        assert_eq!(compact, 0, "pre-epoch timestamp should saturate to 0");
2291    }
2292
2293    #[test]
2294    fn timestamp_ms_sub_second_precision_preserved() {
2295        let ms: u64 = 1705320000999;
2296        let compact = timestamp_to_compact(ms);
2297        let restored = timestamp_from_compact(compact);
2298        assert_eq!(restored, ms, "sub-second ms must be preserved");
2299    }
2300
2301    #[test]
2302    fn timestamp_one_second_after_epoch() {
2303        let secs: u64 = 1577836801; // one second after epoch
2304        let compact = secs_to_compact(secs);
2305        assert_eq!(compact, 1, "one second after epoch should be compact 1");
2306        let restored = secs_from_compact(compact);
2307        assert_eq!(restored, secs, "should restore to original");
2308    }
2309
2310    // ========================================================================
2311    // MessageFlags Tests
2312    // ========================================================================
2313
2314    #[test]
2315    fn message_flags_mine_independent() {
2316        let mut flags = MessageFlags::NONE;
2317        flags.set_mine(true);
2318        assert!(flags.is_mine(), "mine should be set");
2319        assert!(!flags.is_pending(), "pending should not be set");
2320        assert!(!flags.is_failed(), "failed should not be set");
2321    }
2322
2323    #[test]
2324    fn message_flags_pending_independent() {
2325        let mut flags = MessageFlags::NONE;
2326        flags.set_pending(true);
2327        assert!(!flags.is_mine(), "mine should not be set");
2328        assert!(flags.is_pending(), "pending should be set");
2329        assert!(!flags.is_failed(), "failed should not be set");
2330    }
2331
2332    #[test]
2333    fn message_flags_failed_independent() {
2334        let mut flags = MessageFlags::NONE;
2335        flags.set_failed(true);
2336        assert!(!flags.is_mine(), "mine should not be set");
2337        assert!(!flags.is_pending(), "pending should not be set");
2338        assert!(flags.is_failed(), "failed should be set");
2339    }
2340
2341    #[test]
2342    fn message_flags_from_bools_all_false() {
2343        let flags = MessageFlags::from_bools(false, false, false);
2344        assert!(!flags.is_mine());
2345        assert!(!flags.is_pending());
2346        assert!(!flags.is_failed());
2347        assert_eq!(flags, MessageFlags::NONE, "all false should equal NONE");
2348    }
2349
2350    #[test]
2351    fn message_flags_from_bools_all_true() {
2352        let flags = MessageFlags::from_bools(true, true, true);
2353        assert!(flags.is_mine(), "mine should be set");
2354        assert!(flags.is_pending(), "pending should be set");
2355        assert!(flags.is_failed(), "failed should be set");
2356    }
2357
2358    #[test]
2359    fn message_flags_from_bools_various_combos() {
2360        let flags = MessageFlags::from_bools(true, false, true);
2361        assert!(flags.is_mine());
2362        assert!(!flags.is_pending());
2363        assert!(flags.is_failed());
2364
2365        let flags = MessageFlags::from_bools(false, true, false);
2366        assert!(!flags.is_mine());
2367        assert!(flags.is_pending());
2368        assert!(!flags.is_failed());
2369    }
2370
2371    #[test]
2372    fn message_flags_from_all_replied_to_none() {
2373        let flags = MessageFlags::from_all(false, false, false, None);
2374        assert_eq!(flags.replied_to_has_attachment(), None, "None should roundtrip");
2375    }
2376
2377    #[test]
2378    fn message_flags_from_all_replied_to_some_false() {
2379        let flags = MessageFlags::from_all(false, false, false, Some(false));
2380        assert_eq!(flags.replied_to_has_attachment(), Some(false), "Some(false) should roundtrip");
2381    }
2382
2383    #[test]
2384    fn message_flags_from_all_replied_to_some_true() {
2385        let flags = MessageFlags::from_all(false, false, false, Some(true));
2386        assert_eq!(flags.replied_to_has_attachment(), Some(true), "Some(true) should roundtrip");
2387    }
2388
2389    #[test]
2390    fn message_flags_multiple_set_simultaneously() {
2391        let flags = MessageFlags::from_all(true, true, false, Some(true));
2392        assert!(flags.is_mine());
2393        assert!(flags.is_pending());
2394        assert!(!flags.is_failed());
2395        assert_eq!(flags.replied_to_has_attachment(), Some(true));
2396    }
2397
2398    #[test]
2399    fn message_flags_default_is_all_false() {
2400        let flags = MessageFlags::default();
2401        assert!(!flags.is_mine());
2402        assert!(!flags.is_pending());
2403        assert!(!flags.is_failed());
2404        assert_eq!(flags.replied_to_has_attachment(), None);
2405        assert_eq!(flags, MessageFlags::NONE);
2406    }
2407
2408    #[test]
2409    fn message_flags_bit_patterns_correct() {
2410        assert_eq!(MessageFlags::MINE.0, 0b00001, "MINE bit pattern");
2411        assert_eq!(MessageFlags::PENDING.0, 0b00010, "PENDING bit pattern");
2412        assert_eq!(MessageFlags::FAILED.0, 0b00100, "FAILED bit pattern");
2413    }
2414
2415    #[test]
2416    fn message_flags_set_then_clear() {
2417        let mut flags = MessageFlags::from_bools(true, true, true);
2418        flags.set_mine(false);
2419        assert!(!flags.is_mine(), "mine should be cleared");
2420        assert!(flags.is_pending(), "pending should remain set");
2421        assert!(flags.is_failed(), "failed should remain set");
2422    }
2423
2424    #[test]
2425    fn message_flags_replied_to_overwrite() {
2426        let mut flags = MessageFlags::from_all(false, false, false, Some(true));
2427        assert_eq!(flags.replied_to_has_attachment(), Some(true));
2428        flags.set_replied_to_has_attachment(Some(false));
2429        assert_eq!(flags.replied_to_has_attachment(), Some(false), "overwrite should work");
2430        flags.set_replied_to_has_attachment(None);
2431        assert_eq!(flags.replied_to_has_attachment(), None, "clearing to None should work");
2432    }
2433
2434    #[test]
2435    fn message_flags_replied_to_does_not_interfere_with_other_bits() {
2436        let mut flags = MessageFlags::from_bools(true, true, true);
2437        flags.set_replied_to_has_attachment(Some(true));
2438        assert!(flags.is_mine(), "mine should still be set");
2439        assert!(flags.is_pending(), "pending should still be set");
2440        assert!(flags.is_failed(), "failed should still be set");
2441        assert_eq!(flags.replied_to_has_attachment(), Some(true));
2442    }
2443
2444    // ========================================================================
2445    // AttachmentFlags Tests
2446    // ========================================================================
2447
2448    #[test]
2449    fn attachment_flags_downloading_independent() {
2450        let mut flags = AttachmentFlags::NONE;
2451        flags.set_downloading(true);
2452        assert!(flags.is_downloading());
2453        assert!(!flags.is_downloaded());
2454        assert!(!flags.is_short_nonce());
2455    }
2456
2457    #[test]
2458    fn attachment_flags_downloaded_independent() {
2459        let mut flags = AttachmentFlags::NONE;
2460        flags.set_downloaded(true);
2461        assert!(!flags.is_downloading());
2462        assert!(flags.is_downloaded());
2463        assert!(!flags.is_short_nonce());
2464    }
2465
2466    #[test]
2467    fn attachment_flags_short_nonce_independent() {
2468        let mut flags = AttachmentFlags::NONE;
2469        flags.set_short_nonce(true);
2470        assert!(!flags.is_downloading());
2471        assert!(!flags.is_downloaded());
2472        assert!(flags.is_short_nonce());
2473    }
2474
2475    #[test]
2476    fn attachment_flags_from_bools() {
2477        let flags = AttachmentFlags::from_bools(true, false);
2478        assert!(flags.is_downloading());
2479        assert!(!flags.is_downloaded());
2480
2481        let flags = AttachmentFlags::from_bools(false, true);
2482        assert!(!flags.is_downloading());
2483        assert!(flags.is_downloaded());
2484    }
2485
2486    #[test]
2487    fn attachment_flags_all_set() {
2488        let mut flags = AttachmentFlags::NONE;
2489        flags.set_downloading(true);
2490        flags.set_downloaded(true);
2491        flags.set_short_nonce(true);
2492        assert!(flags.is_downloading());
2493        assert!(flags.is_downloaded());
2494        assert!(flags.is_short_nonce());
2495    }
2496
2497    #[test]
2498    fn attachment_flags_set_then_clear() {
2499        let mut flags = AttachmentFlags::NONE;
2500        flags.set_downloading(true);
2501        flags.set_downloaded(true);
2502        flags.set_downloading(false);
2503        assert!(!flags.is_downloading(), "downloading should be cleared");
2504        assert!(flags.is_downloaded(), "downloaded should remain set");
2505    }
2506
2507    #[test]
2508    fn attachment_flags_default_none() {
2509        let flags = AttachmentFlags::NONE;
2510        assert!(!flags.is_downloading());
2511        assert!(!flags.is_downloaded());
2512        assert!(!flags.is_short_nonce());
2513        assert_eq!(flags, AttachmentFlags::default());
2514    }
2515
2516    #[test]
2517    fn attachment_flags_bit_values() {
2518        // Verify the bit constants are distinct
2519        let mut flags = AttachmentFlags::NONE;
2520        flags.set_downloading(true);
2521        assert_eq!(flags.0, 0b0001);
2522
2523        let mut flags = AttachmentFlags::NONE;
2524        flags.set_downloaded(true);
2525        assert_eq!(flags.0, 0b0010);
2526
2527        let mut flags = AttachmentFlags::NONE;
2528        flags.set_short_nonce(true);
2529        assert_eq!(flags.0, 0b0100);
2530    }
2531
2532    // ========================================================================
2533    // NpubInterner Tests
2534    // ========================================================================
2535
2536    #[test]
2537    fn interner_returns_incrementing_handles() {
2538        let mut interner = NpubInterner::new();
2539        let h0 = interner.intern("npub1aaa");
2540        let h1 = interner.intern("npub1bbb");
2541        let h2 = interner.intern("npub1ccc");
2542        assert_eq!(h0, 0, "first intern should be handle 0");
2543        assert_eq!(h1, 1, "second intern should be handle 1");
2544        assert_eq!(h2, 2, "third intern should be handle 2");
2545    }
2546
2547    #[test]
2548    fn interner_lookup_finds_interned() {
2549        let mut interner = NpubInterner::new();
2550        let h = interner.intern("npub1alice");
2551        let found = interner.lookup("npub1alice");
2552        assert_eq!(found, Some(h), "lookup should find interned string");
2553    }
2554
2555    #[test]
2556    fn interner_lookup_returns_none_for_unknown() {
2557        let interner = NpubInterner::new();
2558        assert_eq!(interner.lookup("npub1unknown"), None, "lookup on empty interner should be None");
2559    }
2560
2561    #[test]
2562    fn interner_lookup_returns_none_for_not_interned() {
2563        let mut interner = NpubInterner::new();
2564        interner.intern("npub1alice");
2565        assert_eq!(interner.lookup("npub1bob"), None, "lookup for non-interned should be None");
2566    }
2567
2568    #[test]
2569    fn interner_resolve_returns_string() {
2570        let mut interner = NpubInterner::new();
2571        let h = interner.intern("npub1test123");
2572        assert_eq!(interner.resolve(h), Some("npub1test123"), "resolve should return the original string");
2573    }
2574
2575    #[test]
2576    fn interner_resolve_returns_none_for_no_npub() {
2577        let interner = NpubInterner::new();
2578        assert_eq!(interner.resolve(NO_NPUB), None, "resolve(NO_NPUB) should be None");
2579    }
2580
2581    #[test]
2582    fn interner_resolve_returns_none_for_out_of_bounds() {
2583        let mut interner = NpubInterner::new();
2584        interner.intern("npub1only");
2585        assert_eq!(interner.resolve(999), None, "out-of-bounds handle should resolve to None");
2586    }
2587
2588    #[test]
2589    fn interner_duplicate_returns_same_handle() {
2590        let mut interner = NpubInterner::new();
2591        let h1 = interner.intern("npub1dup");
2592        let h2 = interner.intern("npub1dup");
2593        let h3 = interner.intern("npub1dup");
2594        assert_eq!(h1, h2, "duplicate intern should return same handle");
2595        assert_eq!(h2, h3, "duplicate intern should return same handle");
2596        assert_eq!(interner.len(), 1, "duplicates should not increase length");
2597    }
2598
2599    #[test]
2600    fn interner_100_unique_npubs_stress() {
2601        let mut interner = NpubInterner::new();
2602        let mut handles = Vec::new();
2603
2604        for i in 0..100 {
2605            let npub = format!("npub1stress{:04}", i);
2606            let h = interner.intern(&npub);
2607            handles.push((h, npub));
2608        }
2609
2610        assert_eq!(interner.len(), 100, "should have 100 unique npubs");
2611
2612        // Verify all handles resolve correctly
2613        for (h, npub) in &handles {
2614            assert_eq!(interner.resolve(*h), Some(npub.as_str()),
2615                "handle {} should resolve to {}", h, npub);
2616        }
2617
2618        // Verify all lookups work
2619        for (h, npub) in &handles {
2620            assert_eq!(interner.lookup(npub), Some(*h),
2621                "lookup for {} should return handle {}", npub, h);
2622        }
2623
2624        // Re-interning should return same handles
2625        for (h, npub) in &handles {
2626            assert_eq!(interner.intern(npub), *h,
2627                "re-interning {} should return same handle {}", npub, h);
2628        }
2629        assert_eq!(interner.len(), 100, "re-interning should not grow interner");
2630    }
2631
2632    #[test]
2633    fn interner_memory_usage_reasonable() {
2634        let mut interner = NpubInterner::new();
2635        for i in 0..50 {
2636            interner.intern(&format!("npub1{:0>62}", i));
2637        }
2638        let mem = interner.memory_usage();
2639        // Should be in the ballpark of 50 * (64 bytes string + overhead)
2640        assert!(mem > 0, "memory usage should be positive");
2641        assert!(mem < 100_000, "memory usage for 50 npubs should be under 100KB, was {}", mem);
2642    }
2643
2644    #[test]
2645    fn interner_empty() {
2646        let interner = NpubInterner::new();
2647        assert_eq!(interner.len(), 0);
2648        assert!(interner.is_empty());
2649        assert_eq!(interner.resolve(0), None);
2650        assert_eq!(interner.lookup("anything"), None);
2651        assert!(interner.memory_usage() > 0, "even empty interner has struct overhead");
2652    }
2653
2654    #[test]
2655    fn interner_intern_opt_none() {
2656        let mut interner = NpubInterner::new();
2657        let h = interner.intern_opt(None);
2658        assert_eq!(h, NO_NPUB, "intern_opt(None) should return NO_NPUB");
2659        assert_eq!(interner.len(), 0, "None should not add to interner");
2660    }
2661
2662    #[test]
2663    fn interner_intern_opt_empty_string() {
2664        let mut interner = NpubInterner::new();
2665        let h = interner.intern_opt(Some(""));
2666        assert_eq!(h, NO_NPUB, "intern_opt(Some('')) should return NO_NPUB");
2667    }
2668
2669    #[test]
2670    fn interner_intern_opt_some_value() {
2671        let mut interner = NpubInterner::new();
2672        let h = interner.intern_opt(Some("npub1real"));
2673        assert_ne!(h, NO_NPUB, "intern_opt(Some(value)) should not return NO_NPUB");
2674        assert_eq!(interner.resolve(h), Some("npub1real"));
2675    }
2676
2677    // ========================================================================
2678    // CompactMessageVec Tests
2679    // ========================================================================
2680
2681    /// Helper to create a minimal CompactMessage with given hex ID and timestamp
2682    fn make_compact_msg(hex_id: &str, timestamp: u64) -> CompactMessage {
2683        CompactMessage {
2684            id: encode_message_id(hex_id),
2685            at: timestamp,
2686            flags: MessageFlags::NONE,
2687            npub_idx: NO_NPUB,
2688            replied_to: None,
2689            replied_to_npub_idx: NO_NPUB,
2690            wrapper_id: None,
2691            content: "test".into(),
2692            replied_to_content: None,
2693            attachments: TinyVec::new(),
2694            reactions: TinyVec::new(),
2695            edit_history: None,
2696            preview_metadata: None,
2697            emoji_tags: None,
2698        }
2699    }
2700
2701    #[test]
2702    fn compact_vec_insert_single_message() {
2703        let mut vec = CompactMessageVec::new();
2704        let msg = make_compact_msg(
2705            "1111111111111111111111111111111111111111111111111111111111111111",
2706            100,
2707        );
2708        assert!(vec.insert(msg), "insert should succeed");
2709        assert_eq!(vec.len(), 1);
2710        assert!(!vec.is_empty());
2711    }
2712
2713    #[test]
2714    fn compact_vec_insert_duplicate_rejected() {
2715        let mut vec = CompactMessageVec::new();
2716        let id = "2222222222222222222222222222222222222222222222222222222222222222";
2717        let msg1 = make_compact_msg(id, 100);
2718        let msg2 = make_compact_msg(id, 200); // same ID, different timestamp
2719        assert!(vec.insert(msg1), "first insert should succeed");
2720        assert!(!vec.insert(msg2), "duplicate ID should be rejected");
2721        assert_eq!(vec.len(), 1);
2722    }
2723
2724    #[test]
2725    fn compact_vec_insert_batch_multiple() {
2726        let mut vec = CompactMessageVec::new();
2727        let msgs = vec![
2728            make_compact_msg("aa00000000000000000000000000000000000000000000000000000000000000", 100),
2729            make_compact_msg("bb00000000000000000000000000000000000000000000000000000000000000", 200),
2730            make_compact_msg("cc00000000000000000000000000000000000000000000000000000000000000", 300),
2731        ];
2732        let added = vec.insert_batch(msgs);
2733        assert_eq!(added, 3, "all 3 should be added");
2734        assert_eq!(vec.len(), 3);
2735    }
2736
2737    #[test]
2738    fn compact_vec_insert_batch_dedup() {
2739        let mut vec = CompactMessageVec::new();
2740        let id = "dd00000000000000000000000000000000000000000000000000000000000000";
2741        vec.insert(make_compact_msg(id, 100));
2742
2743        let msgs = vec![
2744            make_compact_msg(id, 200), // duplicate
2745            make_compact_msg("ee00000000000000000000000000000000000000000000000000000000000000", 300),
2746        ];
2747        let added = vec.insert_batch(msgs);
2748        assert_eq!(added, 1, "only non-duplicate should be added");
2749        assert_eq!(vec.len(), 2);
2750    }
2751
2752    #[test]
2753    fn compact_vec_find_by_hex_id() {
2754        let mut vec = CompactMessageVec::new();
2755        let id = "ff00000000000000000000000000000000000000000000000000000000000001";
2756        let mut msg = make_compact_msg(id, 500);
2757        msg.content = "found me".into();
2758        vec.insert(msg);
2759
2760        let found = vec.find_by_hex_id(id);
2761        assert!(found.is_some(), "should find by hex id");
2762        assert_eq!(&*found.unwrap().content, "found me");
2763    }
2764
2765    #[test]
2766    fn compact_vec_find_by_hex_id_not_found() {
2767        let mut vec = CompactMessageVec::new();
2768        vec.insert(make_compact_msg(
2769            "aa00000000000000000000000000000000000000000000000000000000000000", 100,
2770        ));
2771        let found = vec.find_by_hex_id(
2772            "bb00000000000000000000000000000000000000000000000000000000000000",
2773        );
2774        assert!(found.is_none(), "should not find non-existent ID");
2775    }
2776
2777    #[test]
2778    fn compact_vec_find_by_hex_id_empty_string() {
2779        let mut vec = CompactMessageVec::new();
2780        vec.insert(make_compact_msg(
2781            "aa00000000000000000000000000000000000000000000000000000000000000", 100,
2782        ));
2783        assert!(vec.find_by_hex_id("").is_none(), "empty string should return None");
2784    }
2785
2786    #[test]
2787    fn compact_vec_find_by_hex_id_mut() {
2788        let mut vec = CompactMessageVec::new();
2789        let id = "ff00000000000000000000000000000000000000000000000000000000000002";
2790        vec.insert(make_compact_msg(id, 500));
2791
2792        let found = vec.find_by_hex_id_mut(id);
2793        assert!(found.is_some(), "should find mutable ref by hex id");
2794        found.unwrap().content = "modified".into();
2795
2796        // Verify modification stuck
2797        let found = vec.find_by_hex_id(id);
2798        assert_eq!(&*found.unwrap().content, "modified");
2799    }
2800
2801    #[test]
2802    fn compact_vec_contains_hex_id() {
2803        let mut vec = CompactMessageVec::new();
2804        let id = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
2805        vec.insert(make_compact_msg(id, 100));
2806
2807        assert!(vec.contains_hex_id(id), "should contain inserted ID");
2808        assert!(!vec.contains_hex_id(
2809            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2810            "should not contain non-inserted ID");
2811        assert!(!vec.contains_hex_id(""), "empty string should not match");
2812    }
2813
2814    #[test]
2815    fn compact_vec_remove_by_hex_id() {
2816        let mut vec = CompactMessageVec::new();
2817        let id1 = "1100000000000000000000000000000000000000000000000000000000000000";
2818        let id2 = "2200000000000000000000000000000000000000000000000000000000000000";
2819        vec.insert(make_compact_msg(id1, 100));
2820        vec.insert(make_compact_msg(id2, 200));
2821        assert_eq!(vec.len(), 2);
2822
2823        assert!(vec.remove_by_hex_id(id1), "remove should succeed");
2824        assert_eq!(vec.len(), 1);
2825        assert!(!vec.contains_hex_id(id1), "removed ID should not be found");
2826        assert!(vec.contains_hex_id(id2), "remaining ID should still be found");
2827    }
2828
2829    #[test]
2830    fn compact_vec_remove_nonexistent() {
2831        let mut vec = CompactMessageVec::new();
2832        vec.insert(make_compact_msg(
2833            "1100000000000000000000000000000000000000000000000000000000000000", 100,
2834        ));
2835        assert!(!vec.remove_by_hex_id(
2836            "9900000000000000000000000000000000000000000000000000000000000000"),
2837            "removing non-existent should return false");
2838        assert!(!vec.remove_by_hex_id(""), "removing empty should return false");
2839        assert_eq!(vec.len(), 1);
2840    }
2841
2842    #[test]
2843    fn compact_vec_last_timestamp() {
2844        let mut vec = CompactMessageVec::new();
2845        assert_eq!(vec.last_timestamp(), None, "empty vec should have no last timestamp");
2846
2847        vec.insert(make_compact_msg(
2848            "aa00000000000000000000000000000000000000000000000000000000000000", 100,
2849        ));
2850        vec.insert(make_compact_msg(
2851            "bb00000000000000000000000000000000000000000000000000000000000000", 300,
2852        ));
2853        vec.insert(make_compact_msg(
2854            "cc00000000000000000000000000000000000000000000000000000000000000", 200,
2855        ));
2856
2857        let last_ts = vec.last_timestamp().unwrap();
2858        // Messages are sorted by timestamp, so last should be the one with at=300
2859        let expected = timestamp_from_compact(300);
2860        assert_eq!(last_ts, expected, "last timestamp should be the largest");
2861    }
2862
2863    #[test]
2864    fn compact_vec_empty_operations() {
2865        let vec = CompactMessageVec::new();
2866        assert!(vec.is_empty());
2867        assert_eq!(vec.len(), 0);
2868        assert!(vec.last().is_none());
2869        assert!(vec.first().is_none());
2870        assert!(vec.last_timestamp().is_none());
2871        assert!(!vec.contains_hex_id("anything"));
2872        assert!(vec.find_by_hex_id("anything").is_none());
2873    }
2874
2875    #[test]
2876    fn compact_vec_1000_message_stress() {
2877        let mut vec = CompactMessageVec::new();
2878
2879        // Insert 1000 messages
2880        for i in 0..1000u64 {
2881            let id = format!("{:0>64x}", i);
2882            let msg = make_compact_msg(&id, i * 10);
2883            assert!(vec.insert(msg), "insert {} should succeed", i);
2884        }
2885        assert_eq!(vec.len(), 1000);
2886
2887        // Verify every message can be found
2888        for i in 0..1000u64 {
2889            let id = format!("{:0>64x}", i);
2890            assert!(vec.contains_hex_id(&id), "should find message {}", i);
2891            let found = vec.find_by_hex_id(&id).unwrap();
2892            assert_eq!(found.at, i * 10, "timestamp should match for message {}", i);
2893        }
2894
2895        // Verify non-existent IDs are not found
2896        for i in 1000..1010u64 {
2897            let id = format!("{:0>64x}", i);
2898            assert!(!vec.contains_hex_id(&id), "should not find non-existent {}", i);
2899        }
2900
2901        // Messages should be in timestamp order
2902        let timestamps: Vec<u64> = vec.iter().map(|m| m.at).collect();
2903        for w in timestamps.windows(2) {
2904            assert!(w[0] <= w[1], "messages should be sorted by timestamp: {} <= {}", w[0], w[1]);
2905        }
2906    }
2907
2908    #[test]
2909    fn compact_vec_rebuild_index_after_id_change() {
2910        let mut vec = CompactMessageVec::new();
2911        let old_id = "aa00000000000000000000000000000000000000000000000000000000000000";
2912        let new_id = "ff00000000000000000000000000000000000000000000000000000000000000";
2913        vec.insert(make_compact_msg(old_id, 100));
2914
2915        // Mutate the message's ID directly (simulating an ID update like pending -> confirmed)
2916        vec.messages_mut()[0].id = encode_message_id(new_id);
2917        // Index is now stale
2918        assert!(!vec.contains_hex_id(new_id), "stale index should not find new ID");
2919
2920        // Rebuild index
2921        vec.rebuild_index();
2922        assert!(vec.contains_hex_id(new_id), "after rebuild, new ID should be found");
2923        assert!(!vec.contains_hex_id(old_id), "after rebuild, old ID should not be found");
2924    }
2925
2926    #[test]
2927    fn compact_vec_pending_id_lookup() {
2928        let mut vec = CompactMessageVec::new();
2929        let pending = "pending-9876543210";
2930        vec.insert(make_compact_msg(pending, 500));
2931
2932        assert!(vec.contains_hex_id(pending), "should find pending ID");
2933        let found = vec.find_by_hex_id(pending);
2934        assert!(found.is_some(), "should find pending message");
2935        assert_eq!(found.unwrap().id_hex(), pending, "id_hex should match pending string");
2936    }
2937
2938    #[test]
2939    fn compact_vec_out_of_order_insert() {
2940        let mut vec = CompactMessageVec::new();
2941        // Insert messages out of timestamp order
2942        vec.insert(make_compact_msg(
2943            "bb00000000000000000000000000000000000000000000000000000000000000", 300,
2944        ));
2945        vec.insert(make_compact_msg(
2946            "aa00000000000000000000000000000000000000000000000000000000000000", 100,
2947        ));
2948        vec.insert(make_compact_msg(
2949            "cc00000000000000000000000000000000000000000000000000000000000000", 200,
2950        ));
2951
2952        assert_eq!(vec.len(), 3);
2953        // Verify sorted by timestamp
2954        let timestamps: Vec<u64> = vec.iter().map(|m| m.at).collect();
2955        assert_eq!(timestamps, vec![100, 200, 300], "should be sorted by timestamp");
2956
2957        // All lookups should still work
2958        assert!(vec.contains_hex_id("aa00000000000000000000000000000000000000000000000000000000000000"));
2959        assert!(vec.contains_hex_id("bb00000000000000000000000000000000000000000000000000000000000000"));
2960        assert!(vec.contains_hex_id("cc00000000000000000000000000000000000000000000000000000000000000"));
2961    }
2962
2963    #[test]
2964    fn compact_vec_batch_prepend() {
2965        let mut vec = CompactMessageVec::new();
2966        // First insert newer messages
2967        vec.insert(make_compact_msg(
2968            "cc00000000000000000000000000000000000000000000000000000000000000", 300,
2969        ));
2970        vec.insert(make_compact_msg(
2971            "dd00000000000000000000000000000000000000000000000000000000000000", 400,
2972        ));
2973
2974        // Then batch-insert older messages (pagination scenario)
2975        let older = vec![
2976            make_compact_msg("aa00000000000000000000000000000000000000000000000000000000000000", 100),
2977            make_compact_msg("bb00000000000000000000000000000000000000000000000000000000000000", 200),
2978        ];
2979        let added = vec.insert_batch(older);
2980        assert_eq!(added, 2);
2981        assert_eq!(vec.len(), 4);
2982
2983        // Verify order
2984        let timestamps: Vec<u64> = vec.iter().map(|m| m.at).collect();
2985        assert_eq!(timestamps, vec![100, 200, 300, 400]);
2986
2987        // All lookups should work
2988        assert!(vec.contains_hex_id("aa00000000000000000000000000000000000000000000000000000000000000"));
2989        assert!(vec.contains_hex_id("dd00000000000000000000000000000000000000000000000000000000000000"));
2990    }
2991
2992    #[test]
2993    fn compact_vec_clear() {
2994        let mut vec = CompactMessageVec::new();
2995        vec.insert(make_compact_msg(
2996            "aa00000000000000000000000000000000000000000000000000000000000000", 100,
2997        ));
2998        vec.insert(make_compact_msg(
2999            "bb00000000000000000000000000000000000000000000000000000000000000", 200,
3000        ));
3001        assert_eq!(vec.len(), 2);
3002
3003        vec.clear();
3004        assert!(vec.is_empty());
3005        assert_eq!(vec.len(), 0);
3006        assert!(!vec.contains_hex_id("aa00000000000000000000000000000000000000000000000000000000000000"));
3007    }
3008
3009    // ========================================================================
3010    // CompactMessage from_message / to_message Tests
3011    // ========================================================================
3012
3013    /// Helper to create a full Message with all fields populated
3014    fn make_full_message() -> Message {
3015        Message {
3016            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3017            content: "Hello, world!".into(),
3018            replied_to: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3019            replied_to_content: Some("Original message".into()),
3020            replied_to_npub: Some("npub1replier".into()),
3021            replied_to_has_attachment: Some(true),
3022            replied_to_attachment_extension: None,
3023            preview_metadata: Some(SiteMetadata {
3024                domain: "example.com".into(),
3025                og_title: Some("Test Page".into()),
3026                og_description: Some("A test description".into()),
3027                og_image: Some("https://example.com/img.png".into()),
3028                og_url: Some("https://example.com".into()),
3029                og_type: Some("website".into()),
3030                title: Some("Test".into()),
3031                description: Some("Desc".into()),
3032                favicon: Some("https://example.com/favicon.ico".into()),
3033            }),
3034            attachments: vec![Attachment {
3035                id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3036                key: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3037                nonce: "cccccccccccccccccccccccccccccccc".into(), // 32 hex chars = 16 bytes
3038                extension: "png".into(),
3039                name: "photo.png".into(),
3040                url: "https://blossom.example.com".into(),
3041                path: "/tmp/photo.png".into(),
3042                size: 12345,
3043                img_meta: Some(ImageMetadata {
3044                    thumbhash: "abc123".into(),
3045                    width: 800,
3046                    height: 600,
3047                }),
3048                downloading: false,
3049                downloaded: true,
3050                webxdc_topic: None,
3051                group_id: None,
3052                original_hash: None,
3053                scheme_version: None,
3054                mls_filename: None,
3055            }],
3056            reactions: vec![Reaction {
3057                id: "dddd000000000000000000000000000000000000000000000000000000000000".into(),
3058                reference_id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3059                author_id: "npub1reactor".into(),
3060                emoji: "\u{1f44d}".into(), // thumbs up
3061                emoji_url: None,
3062            }],
3063            at: 1705320000000, // 2024-01-15 12:00:00 UTC ms
3064            pending: false,
3065            failed: false,
3066            mine: true,
3067            npub: Some("npub1sender".into()),
3068            wrapper_event_id: Some("eeee000000000000000000000000000000000000000000000000000000000000".into()),
3069            edited: true,
3070            edit_history: Some(vec![
3071                EditEntry { content: "Original".into(), edited_at: 1705320000000 },
3072                EditEntry { content: "Edited".into(), edited_at: 1705320060000 },
3073            ]),
3074            emoji_tags: Vec::new(),
3075        }
3076    }
3077
3078    #[test]
3079    fn compact_message_from_message_roundtrip_all_fields() {
3080        let msg = make_full_message();
3081        let mut interner = NpubInterner::new();
3082        let compact = CompactMessage::from_message(&msg, &mut interner);
3083        let restored = compact.to_message(&interner);
3084
3085        assert_eq!(restored.id, msg.id, "id mismatch");
3086        assert_eq!(restored.content, msg.content, "content mismatch");
3087        assert_eq!(restored.mine, msg.mine, "mine mismatch");
3088        assert_eq!(restored.pending, msg.pending, "pending mismatch");
3089        assert_eq!(restored.failed, msg.failed, "failed mismatch");
3090        assert_eq!(restored.npub, msg.npub, "npub mismatch");
3091        assert_eq!(restored.replied_to, msg.replied_to, "replied_to mismatch");
3092        assert_eq!(restored.replied_to_content, msg.replied_to_content, "replied_to_content mismatch");
3093        assert_eq!(restored.replied_to_npub, msg.replied_to_npub, "replied_to_npub mismatch");
3094        assert_eq!(restored.replied_to_has_attachment, msg.replied_to_has_attachment, "replied_to_has_attachment mismatch");
3095        assert_eq!(restored.wrapper_event_id, msg.wrapper_event_id, "wrapper_event_id mismatch");
3096        assert_eq!(restored.edited, msg.edited, "edited mismatch");
3097        assert_eq!(restored.edit_history, msg.edit_history, "edit_history mismatch");
3098        assert_eq!(restored.preview_metadata, msg.preview_metadata, "preview_metadata mismatch");
3099        // Timestamp loses sub-second precision but seconds should match
3100        assert_eq!(restored.at / 1000, msg.at / 1000, "timestamp seconds mismatch");
3101        // Attachments
3102        assert_eq!(restored.attachments.len(), 1, "should have 1 attachment");
3103        assert_eq!(restored.attachments[0].id, msg.attachments[0].id);
3104        assert_eq!(restored.attachments[0].name, msg.attachments[0].name);
3105        assert_eq!(restored.attachments[0].size, msg.attachments[0].size);
3106        // Reactions
3107        assert_eq!(restored.reactions.len(), 1, "should have 1 reaction");
3108        assert_eq!(restored.reactions[0].emoji, msg.reactions[0].emoji);
3109    }
3110
3111    #[test]
3112    fn compact_message_from_message_owned_roundtrip() {
3113        let msg = make_full_message();
3114        let msg_clone = msg.clone();
3115        let mut interner = NpubInterner::new();
3116        let compact = CompactMessage::from_message_owned(msg, &mut interner);
3117        let restored = compact.to_message(&interner);
3118
3119        assert_eq!(restored.id, msg_clone.id, "id mismatch");
3120        assert_eq!(restored.content, msg_clone.content, "content mismatch");
3121        assert_eq!(restored.mine, msg_clone.mine, "mine mismatch");
3122        assert_eq!(restored.npub, msg_clone.npub, "npub mismatch");
3123        assert_eq!(restored.edit_history, msg_clone.edit_history, "edit_history mismatch");
3124    }
3125
3126    #[test]
3127    fn compact_message_pending_flag() {
3128        let msg = Message {
3129            id: "pending-1234567890".into(),
3130            pending: true,
3131            ..Message::default()
3132        };
3133        let mut interner = NpubInterner::new();
3134        let compact = CompactMessage::from_message(&msg, &mut interner);
3135        assert!(compact.is_pending(), "pending flag should be set");
3136        let restored = compact.to_message(&interner);
3137        assert!(restored.pending, "pending should roundtrip");
3138        assert_eq!(restored.id, "pending-1234567890", "pending ID should roundtrip");
3139    }
3140
3141    #[test]
3142    fn compact_message_failed_flag() {
3143        let msg = Message {
3144            id: "pending-999".into(),
3145            failed: true,
3146            pending: true,
3147            ..Message::default()
3148        };
3149        let mut interner = NpubInterner::new();
3150        let compact = CompactMessage::from_message(&msg, &mut interner);
3151        assert!(compact.is_failed(), "failed flag should be set");
3152        assert!(compact.is_pending(), "pending flag should also be set");
3153        let restored = compact.to_message(&interner);
3154        assert!(restored.failed);
3155        assert!(restored.pending);
3156    }
3157
3158    #[test]
3159    fn compact_message_with_attachments_roundtrip() {
3160        let msg = Message {
3161            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3162            attachments: vec![
3163                Attachment {
3164                    id: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3165                    extension: "jpg".into(),
3166                    name: "sunset.jpg".into(),
3167                    size: 5000,
3168                    downloaded: true,
3169                    ..Attachment::default()
3170                },
3171                Attachment {
3172                    id: "2222222222222222222222222222222222222222222222222222222222222222".into(),
3173                    extension: "mp4".into(),
3174                    name: "video.mp4".into(),
3175                    size: 50000,
3176                    downloaded: false,
3177                    downloading: true,
3178                    ..Attachment::default()
3179                },
3180            ],
3181            ..Message::default()
3182        };
3183        let mut interner = NpubInterner::new();
3184        let compact = CompactMessage::from_message(&msg, &mut interner);
3185        assert_eq!(compact.attachments.len(), 2);
3186
3187        let restored = compact.to_message(&interner);
3188        assert_eq!(restored.attachments.len(), 2);
3189        assert_eq!(restored.attachments[0].name, "sunset.jpg");
3190        assert_eq!(restored.attachments[0].extension, "jpg");
3191        assert!(restored.attachments[0].downloaded);
3192        assert_eq!(restored.attachments[1].name, "video.mp4");
3193        assert!(restored.attachments[1].downloading);
3194        assert!(!restored.attachments[1].downloaded);
3195    }
3196
3197    #[test]
3198    fn compact_message_with_reactions_roundtrip() {
3199        let msg = Message {
3200            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3201            reactions: vec![
3202                Reaction {
3203                    id: "aaa0000000000000000000000000000000000000000000000000000000000000".into(),
3204                    reference_id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3205                    author_id: "npub1alice".into(),
3206                    emoji: "\u{2764}".into(), // heart
3207                    emoji_url: None,
3208                },
3209                Reaction {
3210                    id: "bbb0000000000000000000000000000000000000000000000000000000000000".into(),
3211                    reference_id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3212                    author_id: "npub1bob".into(),
3213                    emoji: "\u{1f525}".into(), // fire
3214                    emoji_url: None,
3215                },
3216            ],
3217            ..Message::default()
3218        };
3219        let mut interner = NpubInterner::new();
3220        let compact = CompactMessage::from_message(&msg, &mut interner);
3221        let restored = compact.to_message(&interner);
3222
3223        assert_eq!(restored.reactions.len(), 2);
3224        assert_eq!(restored.reactions[0].emoji, "\u{2764}");
3225        assert_eq!(restored.reactions[0].author_id, "npub1alice");
3226        assert_eq!(restored.reactions[1].emoji, "\u{1f525}");
3227        assert_eq!(restored.reactions[1].author_id, "npub1bob");
3228    }
3229
3230    #[test]
3231    fn compact_message_with_edit_history_roundtrip() {
3232        let msg = Message {
3233            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3234            content: "Final version".into(),
3235            edited: true,
3236            edit_history: Some(vec![
3237                EditEntry { content: "First draft".into(), edited_at: 1000 },
3238                EditEntry { content: "Second draft".into(), edited_at: 2000 },
3239                EditEntry { content: "Final version".into(), edited_at: 3000 },
3240            ]),
3241            ..Message::default()
3242        };
3243        let mut interner = NpubInterner::new();
3244        let compact = CompactMessage::from_message(&msg, &mut interner);
3245        assert!(compact.is_edited());
3246
3247        let restored = compact.to_message(&interner);
3248        assert!(restored.edited);
3249        let history = restored.edit_history.unwrap();
3250        assert_eq!(history.len(), 3);
3251        assert_eq!(history[0].content, "First draft");
3252        assert_eq!(history[2].content, "Final version");
3253    }
3254
3255    #[test]
3256    fn compact_message_with_preview_metadata_roundtrip() {
3257        let meta = SiteMetadata {
3258            domain: "example.com".into(),
3259            og_title: Some("Title".into()),
3260            og_description: Some("Desc".into()),
3261            og_image: Some("https://example.com/img.png".into()),
3262            og_url: None,
3263            og_type: None,
3264            title: None,
3265            description: None,
3266            favicon: None,
3267        };
3268        let msg = Message {
3269            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3270            preview_metadata: Some(meta.clone()),
3271            ..Message::default()
3272        };
3273        let mut interner = NpubInterner::new();
3274        let compact = CompactMessage::from_message(&msg, &mut interner);
3275        let restored = compact.to_message(&interner);
3276
3277        let restored_meta = restored.preview_metadata.unwrap();
3278        assert_eq!(restored_meta.domain, "example.com");
3279        assert_eq!(restored_meta.og_title, Some("Title".into()));
3280        assert_eq!(restored_meta.og_image, Some("https://example.com/img.png".into()));
3281        assert_eq!(restored_meta.og_url, None);
3282    }
3283
3284    #[test]
3285    fn compact_message_with_replied_to_roundtrip() {
3286        let msg = Message {
3287            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3288            replied_to: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3289            replied_to_content: Some("Original text".into()),
3290            replied_to_npub: Some("npub1original".into()),
3291            replied_to_has_attachment: Some(false),
3292            ..Message::default()
3293        };
3294        let mut interner = NpubInterner::new();
3295        let compact = CompactMessage::from_message(&msg, &mut interner);
3296        assert!(compact.has_reply());
3297
3298        let restored = compact.to_message(&interner);
3299        assert_eq!(restored.replied_to, "1111111111111111111111111111111111111111111111111111111111111111");
3300        assert_eq!(restored.replied_to_content, Some("Original text".into()));
3301        assert_eq!(restored.replied_to_npub, Some("npub1original".into()));
3302        assert_eq!(restored.replied_to_has_attachment, Some(false));
3303    }
3304
3305    #[test]
3306    fn compact_message_empty_roundtrip() {
3307        let msg = Message::default();
3308        let mut interner = NpubInterner::new();
3309        let compact = CompactMessage::from_message(&msg, &mut interner);
3310        let restored = compact.to_message(&interner);
3311
3312        assert_eq!(restored.id, "0000000000000000000000000000000000000000000000000000000000000000",
3313            "empty ID should decode as all zeros hex");
3314        assert_eq!(restored.content, "");
3315        assert!(!restored.mine);
3316        assert!(!restored.pending);
3317        assert!(!restored.failed);
3318        assert!(!restored.edited);
3319        assert_eq!(restored.npub, None);
3320        assert!(restored.replied_to.is_empty() || restored.replied_to == "0000000000000000000000000000000000000000000000000000000000000000");
3321        assert_eq!(restored.replied_to_content, None);
3322        assert_eq!(restored.replied_to_npub, None);
3323        assert_eq!(restored.replied_to_has_attachment, None);
3324        assert_eq!(restored.wrapper_event_id, None);
3325        assert!(restored.attachments.is_empty());
3326        assert!(restored.reactions.is_empty());
3327        assert_eq!(restored.edit_history, None);
3328        assert_eq!(restored.preview_metadata, None);
3329    }
3330
3331    // ========================================================================
3332    // CompactAttachment Tests
3333    // ========================================================================
3334
3335    #[test]
3336    fn compact_attachment_from_attachment_roundtrip() {
3337        let att = Attachment {
3338            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3339            key: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3340            nonce: "aabbccddaabbccddaabbccddaabbccdd".into(), // 32 hex = 16-byte DM nonce
3341            extension: "zip".into(),
3342            name: "archive.zip".into(),
3343            url: "https://blossom.test.com".into(),
3344            path: "/downloads/archive.zip".into(),
3345            size: 99999,
3346            img_meta: None,
3347            downloading: false,
3348            downloaded: true,
3349            webxdc_topic: None,
3350            group_id: None,
3351            original_hash: None,
3352            scheme_version: None,
3353            mls_filename: None,
3354        };
3355
3356        let compact = CompactAttachment::from_attachment(&att);
3357        let restored = compact.to_attachment();
3358
3359        assert_eq!(restored.id, att.id, "id mismatch");
3360        assert_eq!(restored.key, att.key, "key mismatch");
3361        assert_eq!(restored.nonce, att.nonce, "nonce mismatch");
3362        assert_eq!(restored.extension, att.extension, "extension mismatch");
3363        assert_eq!(restored.name, att.name, "name mismatch");
3364        assert_eq!(restored.url, att.url, "url mismatch");
3365        assert_eq!(restored.path, att.path, "path mismatch");
3366        assert_eq!(restored.size, att.size, "size mismatch");
3367        assert_eq!(restored.downloading, att.downloading, "downloading mismatch");
3368        assert_eq!(restored.downloaded, att.downloaded, "downloaded mismatch");
3369    }
3370
3371    #[test]
3372    fn compact_attachment_from_attachment_owned_roundtrip() {
3373        let att = Attachment {
3374            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3375            key: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3376            nonce: "aabbccddaabbccddaabbccddaabbccdd".into(),
3377            extension: "pdf".into(),
3378            name: "document.pdf".into(),
3379            url: "https://server.com".into(),
3380            path: "".into(),
3381            size: 1024,
3382            img_meta: None,
3383            downloading: false,
3384            downloaded: false,
3385            webxdc_topic: None,
3386            group_id: None,
3387            original_hash: None,
3388            scheme_version: None,
3389            mls_filename: None,
3390        };
3391        let att_clone = att.clone();
3392
3393        let compact = CompactAttachment::from_attachment_owned(att);
3394        let restored = compact.to_attachment();
3395
3396        assert_eq!(restored.id, att_clone.id);
3397        assert_eq!(restored.name, att_clone.name);
3398        assert_eq!(restored.size, att_clone.size);
3399    }
3400
3401    #[test]
3402    fn compact_attachment_key_nonce_zeros() {
3403        let att = Attachment {
3404            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3405            key: "".into(), // empty key = legacy derived
3406            nonce: "".into(), // empty nonce
3407            ..Attachment::default()
3408        };
3409        let compact = CompactAttachment::from_attachment(&att);
3410        assert_eq!(compact.key, [0u8; 32], "empty key should be all zeros");
3411        assert_eq!(compact.nonce, [0u8; 16], "empty nonce should be all zeros");
3412
3413        let restored = compact.to_attachment();
3414        assert_eq!(restored.key, "", "zero key should restore as empty string");
3415        assert_eq!(restored.nonce, "", "zero nonce should restore as empty string");
3416    }
3417
3418    #[test]
3419    fn compact_attachment_short_nonce_mls_12byte() {
3420        // Legacy short nonce is 12 bytes = 24 hex chars
3421        let att = Attachment {
3422            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3423            nonce: "aabbccddaabbccddaabbccdd".into(), // 24 hex chars = 12 bytes
3424            ..Attachment::default()
3425        };
3426        let compact = CompactAttachment::from_attachment(&att);
3427        assert!(compact.flags.is_short_nonce(), "12-byte nonce should set short_nonce flag");
3428
3429        let restored = compact.to_attachment();
3430        assert_eq!(restored.nonce, "aabbccddaabbccddaabbccdd", "short nonce should roundtrip");
3431    }
3432
3433    #[test]
3434    fn compact_attachment_long_nonce_dm_16byte() {
3435        // DM nonce is 16 bytes = 32 hex chars
3436        let att = Attachment {
3437            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3438            nonce: "aabbccddaabbccddaabbccddaabbccdd".into(), // 32 hex chars = 16 bytes
3439            ..Attachment::default()
3440        };
3441        let compact = CompactAttachment::from_attachment(&att);
3442        assert!(!compact.flags.is_short_nonce(), "16-byte nonce should NOT set short_nonce flag");
3443
3444        let restored = compact.to_attachment();
3445        assert_eq!(restored.nonce, "aabbccddaabbccddaabbccddaabbccdd", "long nonce should roundtrip");
3446    }
3447
3448    #[test]
3449    fn compact_attachment_id_eq_comparison() {
3450        let att = Attachment {
3451            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3452            ..Attachment::default()
3453        };
3454        let compact = CompactAttachment::from_attachment(&att);
3455
3456        assert!(compact.id_eq("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
3457            "id_eq should match same hex");
3458        assert!(!compact.id_eq("1111111111111111111111111111111111111111111111111111111111111111"),
3459            "id_eq should not match different hex");
3460    }
3461
3462    #[test]
3463    fn compact_attachment_all_optional_fields_none() {
3464        let att = Attachment::default();
3465        let compact = CompactAttachment::from_attachment(&att);
3466
3467        assert!(compact.img_meta.is_none());
3468        assert!(compact.group_id.is_none());
3469        assert!(compact.original_hash.is_none());
3470        assert!(compact.webxdc_topic.is_none());
3471        assert!(compact.mls_filename.is_none());
3472        assert!(compact.scheme_version.is_none());
3473
3474        let restored = compact.to_attachment();
3475        assert!(restored.img_meta.is_none());
3476        assert!(restored.group_id.is_none());
3477        assert!(restored.original_hash.is_none());
3478        assert!(restored.webxdc_topic.is_none());
3479        assert!(restored.mls_filename.is_none());
3480        assert!(restored.scheme_version.is_none());
3481    }
3482
3483    #[test]
3484    fn compact_attachment_all_optional_fields_some() {
3485        let att = Attachment {
3486            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3487            key: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3488            nonce: "aabbccddaabbccddaabbccddaabbccdd".into(),
3489            extension: "xdc".into(),
3490            name: "app.xdc".into(),
3491            url: "https://server.com/file".into(),
3492            path: "/local/app.xdc".into(),
3493            size: 50000,
3494            img_meta: Some(ImageMetadata {
3495                thumbhash: "hash123".into(),
3496                width: 1920,
3497                height: 1080,
3498            }),
3499            downloading: false,
3500            downloaded: true,
3501            webxdc_topic: Some("game-state".into()),
3502            group_id: Some("cccc000000000000000000000000000000000000000000000000000000000000".into()),
3503            original_hash: Some("dddd000000000000000000000000000000000000000000000000000000000000".into()),
3504            scheme_version: Some("mip04-v1".into()),
3505            mls_filename: Some("encrypted.bin".into()),
3506        };
3507
3508        let compact = CompactAttachment::from_attachment(&att);
3509
3510        assert!(compact.img_meta.is_some());
3511        assert!(compact.group_id.is_some());
3512        assert!(compact.original_hash.is_some());
3513        assert!(compact.webxdc_topic.is_some());
3514        assert!(compact.mls_filename.is_some());
3515        assert!(compact.scheme_version.is_some());
3516
3517        let restored = compact.to_attachment();
3518        let meta = restored.img_meta.unwrap();
3519        assert_eq!(meta.thumbhash, "hash123");
3520        assert_eq!(meta.width, 1920);
3521        assert_eq!(meta.height, 1080);
3522        assert_eq!(restored.webxdc_topic, Some("game-state".into()));
3523        assert_eq!(restored.group_id.unwrap(), att.group_id.unwrap());
3524        assert_eq!(restored.original_hash.unwrap(), att.original_hash.unwrap());
3525        assert_eq!(restored.scheme_version, Some("mip04-v1".into()));
3526        assert_eq!(restored.mls_filename, Some("encrypted.bin".into()));
3527    }
3528
3529    // ========================================================================
3530    // CompactReaction Tests
3531    // ========================================================================
3532
3533    #[test]
3534    fn compact_reaction_from_reaction_roundtrip() {
3535        let reaction = Reaction {
3536            id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3537            reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3538            author_id: "npub1alice".into(),
3539            emoji: "+".into(),
3540            emoji_url: None,
3541        };
3542        let mut interner = NpubInterner::new();
3543        let compact = CompactReaction::from_reaction(&reaction, &mut interner);
3544        let restored = compact.to_reaction(&interner);
3545
3546        assert_eq!(restored.id, reaction.id, "id mismatch");
3547        assert_eq!(restored.reference_id, reaction.reference_id, "reference_id mismatch");
3548        assert_eq!(restored.author_id, reaction.author_id, "author_id mismatch");
3549        assert_eq!(restored.emoji, reaction.emoji, "emoji mismatch");
3550    }
3551
3552    #[test]
3553    fn compact_reaction_author_resolved_via_interner() {
3554        let mut interner = NpubInterner::new();
3555        let alice_handle = interner.intern("npub1alice");
3556
3557        let reaction = Reaction {
3558            id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3559            reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3560            author_id: "npub1alice".into(),
3561            emoji: "+".into(),
3562            emoji_url: None,
3563        };
3564        let compact = CompactReaction::from_reaction(&reaction, &mut interner);
3565        assert_eq!(compact.author_idx, alice_handle, "should reuse existing interner handle");
3566
3567        let resolved = interner.resolve(compact.author_idx).unwrap();
3568        assert_eq!(resolved, "npub1alice");
3569    }
3570
3571    #[test]
3572    fn compact_reaction_unicode_emoji() {
3573        let reaction = Reaction {
3574            id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3575            reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3576            author_id: "npub1test".into(),
3577            emoji: "\u{1f431}\u{200d}\u{1f4bb}".into(), // cat with laptop (ZWJ sequence)
3578            emoji_url: None,
3579        };
3580        let mut interner = NpubInterner::new();
3581        let compact = CompactReaction::from_reaction(&reaction, &mut interner);
3582        let restored = compact.to_reaction(&interner);
3583        assert_eq!(restored.emoji, "\u{1f431}\u{200d}\u{1f4bb}", "complex unicode emoji should roundtrip");
3584    }
3585
3586    #[test]
3587    fn compact_reaction_custom_emoji() {
3588        let reaction = Reaction {
3589            id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3590            reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3591            author_id: "npub1test".into(),
3592            emoji: ":cat_heart_eyes:".into(),
3593            emoji_url: None,
3594        };
3595        let mut interner = NpubInterner::new();
3596        let compact = CompactReaction::from_reaction(&reaction, &mut interner);
3597        let restored = compact.to_reaction(&interner);
3598        assert_eq!(restored.emoji, ":cat_heart_eyes:", "custom emoji shortcode should roundtrip");
3599    }
3600
3601    #[test]
3602    fn compact_reaction_owned_conversion() {
3603        let reaction = Reaction {
3604            id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3605            reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3606            author_id: "npub1bob".into(),
3607            emoji: "\u{1f44d}".into(), // thumbs up
3608            emoji_url: None,
3609        };
3610        let reaction_clone = reaction.clone();
3611        let mut interner = NpubInterner::new();
3612        let compact = CompactReaction::from_reaction_owned(reaction, &mut interner);
3613        let restored = compact.to_reaction(&interner);
3614
3615        assert_eq!(restored.id, reaction_clone.id);
3616        assert_eq!(restored.author_id, reaction_clone.author_id);
3617        assert_eq!(restored.emoji, reaction_clone.emoji);
3618    }
3619
3620    // ========================================================================
3621    // TinyVec Tests
3622    // ========================================================================
3623
3624    #[test]
3625    fn tinyvec_empty() {
3626        let tv: TinyVec<u32> = TinyVec::new();
3627        assert!(tv.is_empty());
3628        assert_eq!(tv.len(), 0);
3629        assert_eq!(tv.as_slice(), &[] as &[u32]);
3630        assert_eq!(tv.first(), None);
3631        assert_eq!(tv.last(), None);
3632    }
3633
3634    #[test]
3635    fn tinyvec_from_vec_and_back() {
3636        let original = vec![1u32, 2, 3, 4, 5];
3637        let tv = TinyVec::from_vec(original.clone());
3638        assert_eq!(tv.len(), 5);
3639        assert_eq!(tv.to_vec(), original);
3640    }
3641
3642    #[test]
3643    fn tinyvec_indexing() {
3644        let tv = TinyVec::from_vec(vec![10u32, 20, 30]);
3645        assert_eq!(tv[0], 10);
3646        assert_eq!(tv[1], 20);
3647        assert_eq!(tv[2], 30);
3648        assert_eq!(tv.get(0), Some(&10));
3649        assert_eq!(tv.get(3), None);
3650    }
3651
3652    #[test]
3653    fn tinyvec_push() {
3654        let mut tv = TinyVec::from_vec(vec![1u32, 2]);
3655        tv.push(3);
3656        assert_eq!(tv.len(), 3);
3657        assert_eq!(tv.to_vec(), vec![1, 2, 3]);
3658    }
3659
3660    #[test]
3661    fn tinyvec_clone() {
3662        let tv = TinyVec::from_vec(vec!["hello".to_string(), "world".to_string()]);
3663        let cloned = tv.clone();
3664        assert_eq!(cloned.len(), 2);
3665        assert_eq!(cloned[0], "hello");
3666        assert_eq!(cloned[1], "world");
3667    }
3668
3669    #[test]
3670    fn tinyvec_retain() {
3671        let mut tv = TinyVec::from_vec(vec![1u32, 2, 3, 4, 5]);
3672        tv.retain(|&x| x % 2 == 0);
3673        assert_eq!(tv.to_vec(), vec![2, 4]);
3674    }
3675
3676    #[test]
3677    fn tinyvec_empty_from_empty_vec() {
3678        let tv = TinyVec::<u32>::from_vec(vec![]);
3679        assert!(tv.is_empty());
3680        assert_eq!(tv.len(), 0);
3681    }
3682
3683    #[test]
3684    fn tinyvec_iter() {
3685        let tv = TinyVec::from_vec(vec![10u32, 20, 30]);
3686        let sum: u32 = tv.iter().sum();
3687        assert_eq!(sum, 60);
3688    }
3689
3690    #[test]
3691    fn tinyvec_any() {
3692        let tv = TinyVec::from_vec(vec![1u32, 2, 3]);
3693        assert!(tv.any(|&x| x == 2));
3694        assert!(!tv.any(|&x| x == 99));
3695    }
3696
3697    // ========================================================================
3698    // hex_to_bytes_16 / bytes_to_hex_string Tests
3699    // ========================================================================
3700
3701    #[test]
3702    fn hex_to_bytes_16_full_32_chars() {
3703        let hex = "aabbccddaabbccddaabbccddaabbccdd";
3704        let bytes = hex_to_bytes_16(hex);
3705        assert_eq!(bytes[0], 0xaa);
3706        assert_eq!(bytes[1], 0xbb);
3707        assert_eq!(bytes[15], 0xdd);
3708    }
3709
3710    #[test]
3711    fn hex_to_bytes_16_short_input_padded() {
3712        // Short input gets right-padded with '0' before decode
3713        let hex = "aabb";
3714        let bytes = hex_to_bytes_16(hex);
3715        assert_eq!(bytes[0], 0xaa);
3716        assert_eq!(bytes[1], 0xbb);
3717        // Remaining bytes should be 0 (from padding)
3718        for i in 2..16 {
3719            assert_eq!(bytes[i], 0, "byte {} should be 0 from padding", i);
3720        }
3721    }
3722
3723    #[test]
3724    fn bytes_to_hex_string_roundtrip() {
3725        let bytes: Vec<u8> = vec![0xaa, 0xbb, 0xcc, 0xdd];
3726        let hex = bytes_to_hex_string(&bytes);
3727        assert_eq!(hex, "aabbccdd");
3728    }
3729}