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    /// NIP-40 expiry as unix SECONDS (0 = permanent). u32 holds it until 2106
953    /// and costs 4 bytes inline — self-destruct messages are rare and short-
954    /// lived, so a boxed Option would only add heap churn on the purge path.
955    pub expiration_secs: u32,
956    /// Packed boolean flags (mine, pending, failed, replied_to_has_attachment)
957    pub flags: MessageFlags,
958    /// Index into NpubInterner for sender's npub (NO_NPUB if none)
959    pub npub_idx: u16,
960    /// Replied-to message ID (boxed - None for ~70% of messages saves 24 bytes)
961    pub replied_to: Option<Box<[u8; 32]>>,
962    /// Index into NpubInterner for replied-to author (NO_NPUB if none)
963    pub replied_to_npub_idx: u16,
964    /// Wrapper event ID for gift-wrapped messages (boxed - saves 25 bytes when None)
965    pub wrapper_id: Option<Box<[u8; 32]>>,
966
967    // Variable-length fields - optimized for memory
968    /// Message content (Box<str> = 16 bytes vs String's 24 bytes)
969    pub content: Box<str>,
970    /// Content of replied-to message
971    pub replied_to_content: Option<Box<str>>,
972    /// File attachments (CompactAttachment = ~120 bytes vs Attachment's ~320 bytes)
973    pub attachments: TinyVec<CompactAttachment>,
974    /// Emoji reactions (CompactReaction = ~82 bytes vs Reaction's ~292 bytes)
975    pub reactions: TinyVec<CompactReaction>,
976    /// Edit history - boxed since <1% of messages are edited (saves 16 bytes inline)
977    #[allow(clippy::box_collection)]
978    pub edit_history: Option<Box<Vec<EditEntry>>>,
979    /// Link preview metadata - boxed since ~216 bytes but rare (saves ~208 bytes)
980    pub preview_metadata: Option<Box<SiteMetadata>>,
981    /// NIP-30 emoji tags travelling with this rumor — boxed because the
982    /// vast majority of messages have none, so the cold path stays cheap.
983    #[allow(clippy::box_collection)]
984    pub emoji_tags: Option<Box<Vec<crate::types::EmojiTag>>>,
985    /// Bot routing targets as interned npub handles — boxed because only
986    /// command invocations carry any.
987    #[allow(clippy::box_collection)]
988    pub addressed_bots: Option<Box<Vec<u16>>>,
989}
990
991impl CompactMessage {
992    /// Check if this message has a replied-to reference
993    #[inline]
994    pub fn has_reply(&self) -> bool {
995        self.replied_to.is_some()
996    }
997
998    /// Check if this message has been edited
999    #[inline]
1000    pub fn is_edited(&self) -> bool {
1001        self.edit_history.is_some()
1002    }
1003
1004    /// Get the message ID as a string (hex for event IDs, "pending-..." for pending)
1005    #[inline]
1006    pub fn id_hex(&self) -> String {
1007        decode_message_id(&self.id)
1008    }
1009
1010    /// Get the replied-to ID as a hex string, or empty if none
1011    #[inline]
1012    pub fn replied_to_hex(&self) -> String {
1013        match &self.replied_to {
1014            Some(id) => bytes_to_hex_32(id),
1015            None => String::new(),
1016        }
1017    }
1018
1019    /// Get wrapper ID as hex string if present
1020    #[inline]
1021    pub fn wrapper_id_hex(&self) -> Option<String> {
1022        self.wrapper_id.as_ref().map(|id| bytes_to_hex_32(id))
1023    }
1024
1025    /// Get timestamp as milliseconds (for compatibility with frontend)
1026    #[inline]
1027    pub fn timestamp_ms(&self) -> u64 {
1028        timestamp_from_compact(self.at)
1029    }
1030
1031    /// Apply an edit to this message.
1032    ///
1033    /// `emoji_tags` are the NIP-30 custom-emoji tags resolved from the new
1034    /// content; they're adopted only when this edit is the newest revision so
1035    /// an out-of-order older edit can't clobber the live content's emoji.
1036    pub fn apply_edit(&mut self, new_content: String, edited_at: u64, emoji_tags: Vec<crate::types::EmojiTag>) {
1037        // Initialize edit history with original content if not present
1038        if self.edit_history.is_none() {
1039            self.edit_history = Some(Box::new(vec![EditEntry {
1040                content: self.content.to_string(),
1041                edited_at: self.timestamp_ms(), // Convert compact to ms
1042            }]));
1043        }
1044
1045        let mut is_latest = true;
1046        if let Some(ref mut history) = self.edit_history {
1047            // Deduplicate: skip if we already have this edit
1048            if history.iter().any(|e| e.edited_at == edited_at) {
1049                return;
1050            }
1051
1052            // Add new edit to history
1053            history.push(EditEntry {
1054                content: new_content.clone(),
1055                edited_at,
1056            });
1057
1058            // Sort by timestamp
1059            history.sort_by_key(|e| e.edited_at);
1060            is_latest = history.last().map(|e| e.edited_at == edited_at).unwrap_or(true);
1061        }
1062
1063        // Only the newest revision drives the visible content + emoji.
1064        if is_latest {
1065            self.content = new_content.into_boxed_str();
1066            self.emoji_tags = if emoji_tags.is_empty() { None } else { Some(Box::new(emoji_tags)) };
1067        }
1068    }
1069
1070    /// Get replied_to_has_attachment from flags
1071    #[inline]
1072    pub fn replied_to_has_attachment(&self) -> Option<bool> {
1073        self.flags.replied_to_has_attachment()
1074    }
1075
1076    /// Add a reaction to this message
1077    /// Note: Since TinyVec is immutable, this rebuilds the entire reactions list
1078    pub fn add_reaction(&mut self, reaction: Reaction, interner: &mut NpubInterner) -> bool {
1079        // Convert to binary ID for comparison
1080        let reaction_id = hex_to_bytes_32(&reaction.id);
1081
1082        // Check if already exists
1083        if self.reactions.iter().any(|r| r.id == reaction_id) {
1084            return false;
1085        }
1086
1087        // Convert to compact and rebuild
1088        let compact = CompactReaction::from_reaction_owned(reaction, interner);
1089        let mut reactions = self.reactions.to_vec();
1090        reactions.push(compact);
1091        self.reactions = TinyVec::from_vec(reactions);
1092        true
1093    }
1094
1095    /// Remove a reaction by its hex event id. Returns true if one was removed.
1096    pub fn remove_reaction(&mut self, reaction_id: &str) -> bool {
1097        let target = hex_to_bytes_32(reaction_id);
1098        if !self.reactions.iter().any(|r| r.id == target) {
1099            return false;
1100        }
1101        let mut reactions = self.reactions.to_vec();
1102        reactions.retain(|r| r.id != target);
1103        self.reactions = TinyVec::from_vec(reactions);
1104        true
1105    }
1106
1107    // Flag accessors for compatibility
1108    #[inline]
1109    pub fn is_mine(&self) -> bool { self.flags.is_mine() }
1110    #[inline]
1111    pub fn is_pending(&self) -> bool { self.flags.is_pending() }
1112    #[inline]
1113    pub fn is_failed(&self) -> bool { self.flags.is_failed() }
1114
1115    // Flag setters
1116    #[inline]
1117    pub fn set_pending(&mut self, value: bool) { self.flags.set_pending(value); }
1118    #[inline]
1119    pub fn set_failed(&mut self, value: bool) { self.flags.set_failed(value); }
1120    #[inline]
1121    pub fn set_mine(&mut self, value: bool) { self.flags.set_mine(value); }
1122}
1123
1124// ============================================================================
1125// Compact Message Vec with Binary Search
1126// ============================================================================
1127
1128/// Sorted message storage with O(log n) lookup by ID.
1129///
1130/// Messages are stored sorted by timestamp. A separate index provides
1131/// O(log n) lookup by message ID using binary search.
1132#[derive(Clone, Debug, Default)]
1133pub struct CompactMessageVec {
1134    /// Messages sorted by timestamp (ascending)
1135    messages: Vec<CompactMessage>,
1136    /// Index for ID lookup: (id, position in messages), sorted by id
1137    id_index: Vec<([u8; 32], u32)>,
1138}
1139
1140impl CompactMessageVec {
1141    pub fn new() -> Self {
1142        Self {
1143            messages: Vec::new(),
1144            id_index: Vec::new(),
1145        }
1146    }
1147
1148    pub fn with_capacity(capacity: usize) -> Self {
1149        Self {
1150            messages: Vec::with_capacity(capacity),
1151            id_index: Vec::with_capacity(capacity),
1152        }
1153    }
1154
1155    /// Number of messages
1156    #[inline]
1157    pub fn len(&self) -> usize {
1158        self.messages.len()
1159    }
1160
1161    #[inline]
1162    pub fn is_empty(&self) -> bool {
1163        self.messages.is_empty()
1164    }
1165
1166    /// Get all messages (sorted by timestamp)
1167    #[inline]
1168    pub fn messages(&self) -> &[CompactMessage] {
1169        &self.messages
1170    }
1171
1172    /// Get a mutable reference to all messages
1173    #[inline]
1174    pub fn messages_mut(&mut self) -> &mut Vec<CompactMessage> {
1175        &mut self.messages
1176    }
1177
1178    /// Iterate over messages (supports .rev())
1179    #[inline]
1180    pub fn iter(&self) -> std::slice::Iter<'_, CompactMessage> {
1181        self.messages.iter()
1182    }
1183
1184    /// Iterate over messages mutably
1185    #[inline]
1186    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, CompactMessage> {
1187        self.messages.iter_mut()
1188    }
1189
1190    /// Get the last message
1191    #[inline]
1192    pub fn last(&self) -> Option<&CompactMessage> {
1193        self.messages.last()
1194    }
1195
1196    /// Get last message timestamp (in milliseconds)
1197    #[inline]
1198    pub fn last_timestamp(&self) -> Option<u64> {
1199        self.messages.last().map(|m| timestamp_from_compact(m.at))
1200    }
1201
1202    /// Get the first message
1203    #[inline]
1204    pub fn first(&self) -> Option<&CompactMessage> {
1205        self.messages.first()
1206    }
1207
1208    /// Find a message by ID using binary search - O(log n)
1209    pub fn find_by_id(&self, id: &[u8; 32]) -> Option<&CompactMessage> {
1210        let pos = self.id_index
1211            .binary_search_by(|(idx_id, _)| idx_id.cmp(id))
1212            .ok()?;
1213        let msg_pos = self.id_index[pos].1 as usize;
1214        self.messages.get(msg_pos)
1215    }
1216
1217    /// Find a message by ID (mutable) - O(log n)
1218    pub fn find_by_id_mut(&mut self, id: &[u8; 32]) -> Option<&mut CompactMessage> {
1219        let pos = self.id_index
1220            .binary_search_by(|(idx_id, _)| idx_id.cmp(id))
1221            .ok()?;
1222        let msg_pos = self.id_index[pos].1 as usize;
1223        self.messages.get_mut(msg_pos)
1224    }
1225
1226    /// Find a message by ID string (hex or pending) - O(log n)
1227    pub fn find_by_hex_id(&self, id_str: &str) -> Option<&CompactMessage> {
1228        if id_str.is_empty() {
1229            return None;
1230        }
1231        let id = encode_message_id(id_str);
1232        self.find_by_id(&id)
1233    }
1234
1235    /// Find a message by ID string (mutable) - O(log n)
1236    pub fn find_by_hex_id_mut(&mut self, id_str: &str) -> Option<&mut CompactMessage> {
1237        if id_str.is_empty() {
1238            return None;
1239        }
1240        let id = encode_message_id(id_str);
1241        self.find_by_id_mut(&id)
1242    }
1243
1244    /// Check if a message with the given ID exists - O(log n)
1245    pub fn contains_id(&self, id: &[u8; 32]) -> bool {
1246        self.id_index
1247            .binary_search_by(|(idx_id, _)| idx_id.cmp(id))
1248            .is_ok()
1249    }
1250
1251    /// Remove a message by hex ID string. Returns true if removed.
1252    pub fn remove_by_hex_id(&mut self, id_str: &str) -> bool {
1253        if id_str.is_empty() {
1254            return false;
1255        }
1256        let id = encode_message_id(id_str);
1257        // Find position in id_index
1258        let idx_pos = match self.id_index.binary_search_by(|(idx_id, _)| idx_id.cmp(&id)) {
1259            Ok(pos) => pos,
1260            Err(_) => return false,
1261        };
1262        let msg_pos = self.id_index[idx_pos].1 as usize;
1263        // Remove from messages vec
1264        self.messages.remove(msg_pos);
1265        // Rebuild index since positions shifted
1266        self.rebuild_index();
1267        true
1268    }
1269
1270    /// Check if a message with the given ID string exists - O(log n)
1271    pub fn contains_hex_id(&self, id_str: &str) -> bool {
1272        if id_str.is_empty() {
1273            return false;
1274        }
1275        let id = encode_message_id(id_str);
1276        self.contains_id(&id)
1277    }
1278
1279    /// Insert a message, maintaining sort order by timestamp.
1280    ///
1281    /// Returns true if the message was added, false if duplicate ID.
1282    ///
1283    /// **Performance**: O(log n) for append (common case), O(n) for out-of-order insert.
1284    pub fn insert(&mut self, msg: CompactMessage) -> bool {
1285        // Check for duplicate ID - O(log n)
1286        if self.contains_id(&msg.id) {
1287            return false;
1288        }
1289
1290        let msg_id = msg.id;
1291
1292        // Fast path: append if message is newer than or equal to last (common case)
1293        // This is O(log n) for the index insert only
1294        if self.messages.last().is_none_or(|last| msg.at >= last.at) {
1295            let msg_pos = self.messages.len() as u32;
1296            self.messages.push(msg);
1297
1298            // Insert into id_index (maintain sorted order by ID) - O(log n) search + O(n) shift
1299            // But the shift is typically small since IDs are random/sequential
1300            let idx_pos = self.id_index
1301                .binary_search_by(|(id, _)| id.cmp(&msg_id))
1302                .unwrap_err();
1303            self.id_index.insert(idx_pos, (msg_id, msg_pos));
1304
1305            return true;
1306        }
1307
1308        // Slow path: out-of-order insert (rare for real-time chat)
1309        // Find insertion position by timestamp
1310        let msg_pos = match self.messages.binary_search_by(|m| m.at.cmp(&msg.at)) {
1311            Ok(pos) => pos,
1312            Err(pos) => pos,
1313        };
1314
1315        // Update id_index positions for messages that will shift - O(n)
1316        for (_, pos) in &mut self.id_index {
1317            if *pos >= msg_pos as u32 {
1318                *pos += 1;
1319            }
1320        }
1321
1322        // Insert into messages - O(n)
1323        self.messages.insert(msg_pos, msg);
1324
1325        // Insert into id_index - O(n)
1326        let idx_pos = self.id_index
1327            .binary_search_by(|(id, _)| id.cmp(&msg_id))
1328            .unwrap_err();
1329        self.id_index.insert(idx_pos, (msg_id, msg_pos as u32));
1330
1331        true
1332    }
1333
1334    /// Rebuild the ID index (call after bulk modifications)
1335    pub fn rebuild_index(&mut self) {
1336        self.id_index.clear();
1337        self.id_index.reserve(self.messages.len());
1338        for (pos, msg) in self.messages.iter().enumerate() {
1339            self.id_index.push((msg.id, pos as u32));
1340        }
1341        self.id_index.sort_by(|(a, _), (b, _)| a.cmp(b));
1342    }
1343
1344    /// Batch insert messages - optimized for different scenarios.
1345    ///
1346    /// Returns the number of messages actually added (excludes duplicates).
1347    ///
1348    /// **Performance**:
1349    /// - Append case (newer msgs): O(k log n) where k = new messages
1350    /// - Prepend case (older msgs): O(k log n + k)
1351    /// - Mixed: O(n log n) full sort
1352    pub fn insert_batch(&mut self, messages: impl IntoIterator<Item = CompactMessage>) -> usize {
1353        let messages: Vec<_> = messages.into_iter().collect();
1354        if messages.is_empty() {
1355            return 0;
1356        }
1357
1358        // Quick dedup check using the index
1359        let mut to_add: Vec<CompactMessage> = Vec::with_capacity(messages.len());
1360        for msg in messages {
1361            if !self.contains_id(&msg.id) {
1362                to_add.push(msg);
1363            }
1364        }
1365
1366        if to_add.is_empty() {
1367            return 0;
1368        }
1369
1370        let added = to_add.len();
1371
1372        // Determine the insertion strategy based on timestamps
1373        let our_first = self.messages.first().map(|m| m.at);
1374        let our_last = self.messages.last().map(|m| m.at);
1375        let their_min = to_add.iter().map(|m| m.at).min().unwrap();
1376        let their_max = to_add.iter().map(|m| m.at).max().unwrap();
1377
1378        if self.messages.is_empty() {
1379            // Empty vec - just add and sort
1380            self.messages = to_add;
1381            self.messages.sort_by_key(|m| m.at);
1382            self.rebuild_index();
1383        } else if their_min >= our_last.unwrap() {
1384            // All new messages are NEWER - append path (common for real-time)
1385            to_add.sort_by_key(|m| m.at);
1386            let base_pos = self.messages.len() as u32;
1387            for (i, msg) in to_add.into_iter().enumerate() {
1388                let msg_id = msg.id;
1389                self.messages.push(msg);
1390                // Insert into index
1391                let idx_pos = self.id_index
1392                    .binary_search_by(|(id, _)| id.cmp(&msg_id))
1393                    .unwrap_err();
1394                self.id_index.insert(idx_pos, (msg_id, base_pos + i as u32));
1395            }
1396        } else if their_max <= our_first.unwrap() {
1397            // All new messages are OLDER - prepend path (common for pagination)
1398            to_add.sort_by_key(|m| m.at);
1399            let prepend_count = to_add.len();
1400
1401            // Shift all existing index positions
1402            for (_, pos) in &mut self.id_index {
1403                *pos += prepend_count as u32;
1404            }
1405
1406            // Build new index entries (already sorted by construction since to_add is sorted by timestamp)
1407            let mut new_index_entries: Vec<_> = to_add.iter()
1408                .enumerate()
1409                .map(|(i, msg)| (msg.id, i as u32))
1410                .collect();
1411            new_index_entries.sort_by(|(a, _), (b, _)| a.cmp(b));
1412
1413            // Merge sorted index entries in O(n + k) instead of O(k * n)
1414            let old_index = std::mem::take(&mut self.id_index);
1415            self.id_index.reserve(old_index.len() + new_index_entries.len());
1416
1417            let mut old_iter = old_index.into_iter().peekable();
1418            let mut new_iter = new_index_entries.into_iter().peekable();
1419
1420            while old_iter.peek().is_some() || new_iter.peek().is_some() {
1421                match (old_iter.peek(), new_iter.peek()) {
1422                    (Some((old_id, _)), Some((new_id, _))) => {
1423                        if old_id < new_id {
1424                            self.id_index.push(old_iter.next().unwrap());
1425                        } else {
1426                            self.id_index.push(new_iter.next().unwrap());
1427                        }
1428                    }
1429                    (Some(_), None) => self.id_index.push(old_iter.next().unwrap()),
1430                    (None, Some(_)) => self.id_index.push(new_iter.next().unwrap()),
1431                    (None, None) => break,
1432                }
1433            }
1434
1435            // Prepend messages
1436            let mut new_messages = to_add;
1437            new_messages.append(&mut self.messages);
1438            self.messages = new_messages;
1439        } else {
1440            // Mixed timestamps - fall back to full sort
1441            self.messages.extend(to_add);
1442            self.messages.sort_by_key(|m| m.at);
1443            self.rebuild_index();
1444        }
1445
1446        added
1447    }
1448
1449    /// Total memory used (approximate)
1450    pub fn memory_usage(&self) -> usize {
1451        std::mem::size_of::<Self>()
1452            + self.messages.capacity() * std::mem::size_of::<CompactMessage>()
1453            + self.id_index.capacity() * std::mem::size_of::<([u8; 32], u32)>()
1454            // Note: doesn't include heap allocations inside CompactMessage
1455    }
1456
1457    /// Drain messages from a range (rebuilds index after)
1458    pub fn drain(&mut self, range: std::ops::Range<usize>) -> std::vec::Drain<'_, CompactMessage> {
1459        let drain = self.messages.drain(range);
1460        // Note: caller should call rebuild_index() after consuming the drain
1461        drain
1462    }
1463
1464    /// Sort messages by a key (rebuilds index after)
1465    pub fn sort_by_key<K, F>(&mut self, f: F)
1466    where
1467        F: FnMut(&CompactMessage) -> K,
1468        K: Ord,
1469    {
1470        self.messages.sort_by_key(f);
1471        self.rebuild_index();
1472    }
1473
1474    /// Clear all messages
1475    pub fn clear(&mut self) {
1476        self.messages.clear();
1477        self.id_index.clear();
1478    }
1479}
1480
1481// ============================================================================
1482// Conversion from/to Message
1483// ============================================================================
1484
1485use crate::types::Message;
1486
1487impl CompactMessage {
1488    /// Convert from a regular Message (borrowed), interning npubs
1489    pub fn from_message(msg: &Message, interner: &mut NpubInterner) -> Self {
1490        Self {
1491            id: encode_message_id(&msg.id),
1492            at: timestamp_to_compact(msg.at),
1493            flags: MessageFlags::from_all(msg.mine, msg.pending, msg.failed, msg.replied_to_has_attachment),
1494            npub_idx: interner.intern_opt(msg.npub.as_deref()),
1495            // Box replied_to only when present (saves 24 bytes when None)
1496            replied_to: if msg.replied_to.is_empty() {
1497                None
1498            } else {
1499                Some(Box::new(hex_to_bytes_32(&msg.replied_to)))
1500            },
1501            replied_to_npub_idx: interner.intern_opt(msg.replied_to_npub.as_deref()),
1502            // Box wrapper_id (saves 25 bytes when None)
1503            wrapper_id: msg.wrapper_event_id.as_ref().map(|s| Box::new(hex_to_bytes_32(s))),
1504            expiration_secs: msg.expiration.map(|e| e as u32).unwrap_or(0),
1505            // Box<str> for content (saves 8 bytes per field)
1506            content: msg.content.clone().into_boxed_str(),
1507            replied_to_content: msg.replied_to_content.as_ref().map(|s| s.clone().into_boxed_str()),
1508            // Convert attachments to compact format
1509            attachments: TinyVec::from_vec(
1510                msg.attachments.iter()
1511                    .map(CompactAttachment::from_attachment)
1512                    .collect()
1513            ),
1514            // Convert reactions to compact format
1515            reactions: TinyVec::from_vec(
1516                msg.reactions.iter()
1517                    .map(|r| CompactReaction::from_reaction(r, interner))
1518                    .collect()
1519            ),
1520            // Box rare fields to save inline space
1521            edit_history: msg.edit_history.clone().map(Box::new),
1522            preview_metadata: msg.preview_metadata.clone().map(Box::new),
1523            emoji_tags: if msg.emoji_tags.is_empty() {
1524                None
1525            } else {
1526                Some(Box::new(msg.emoji_tags.clone()))
1527            },
1528            addressed_bots: if msg.addressed_bots.is_empty() {
1529                None
1530            } else {
1531                Some(Box::new(msg.addressed_bots.iter().map(|n| interner.intern(n)).collect()))
1532            },
1533        }
1534    }
1535
1536    /// Convert from a regular Message (owned) - ZERO-COPY for strings!
1537    ///
1538    /// Takes ownership of the Message and moves strings directly.
1539    /// Use this when you don't need the original Message anymore.
1540    pub fn from_message_owned(msg: Message, interner: &mut NpubInterner) -> Self {
1541        Self {
1542            id: encode_message_id(&msg.id),
1543            at: timestamp_to_compact(msg.at),
1544            flags: MessageFlags::from_all(msg.mine, msg.pending, msg.failed, msg.replied_to_has_attachment),
1545            npub_idx: interner.intern_opt(msg.npub.as_deref()),
1546            // Box replied_to only when present (saves 24 bytes when None)
1547            replied_to: if msg.replied_to.is_empty() {
1548                None
1549            } else {
1550                Some(Box::new(hex_to_bytes_32(&msg.replied_to)))
1551            },
1552            replied_to_npub_idx: interner.intern_opt(msg.replied_to_npub.as_deref()),
1553            // Box wrapper_id (saves 25 bytes when None)
1554            wrapper_id: msg.wrapper_event_id.as_ref().map(|s| Box::new(hex_to_bytes_32(s))),
1555            expiration_secs: msg.expiration.map(|e| e as u32).unwrap_or(0),
1556            // Zero-copy: into_boxed_str() reuses the String's buffer!
1557            content: msg.content.into_boxed_str(),
1558            replied_to_content: msg.replied_to_content.map(|s| s.into_boxed_str()),
1559            // Convert attachments to compact format (zero-copy where possible)
1560            attachments: TinyVec::from_vec(
1561                msg.attachments.into_iter()
1562                    .map(CompactAttachment::from_attachment_owned)
1563                    .collect()
1564            ),
1565            // Convert reactions to compact format (zero-copy for emoji string)
1566            reactions: TinyVec::from_vec(
1567                msg.reactions.into_iter()
1568                    .map(|r| CompactReaction::from_reaction_owned(r, interner))
1569                    .collect()
1570            ),
1571            // Box rare fields to save inline space
1572            edit_history: msg.edit_history.map(Box::new),
1573            preview_metadata: msg.preview_metadata.map(Box::new),
1574            emoji_tags: if msg.emoji_tags.is_empty() {
1575                None
1576            } else {
1577                Some(Box::new(msg.emoji_tags))
1578            },
1579            addressed_bots: if msg.addressed_bots.is_empty() {
1580                None
1581            } else {
1582                Some(Box::new(msg.addressed_bots.iter().map(|n| interner.intern(n)).collect()))
1583            },
1584        }
1585    }
1586
1587    /// Convert back to a regular Message, resolving npubs from interner
1588    pub fn to_message(&self, interner: &NpubInterner) -> Message {
1589        Message {
1590            id: self.id_hex(),
1591            at: self.timestamp_ms(), // Convert compact back to ms
1592            expiration: if self.expiration_secs == 0 { None } else { Some(self.expiration_secs as u64) },
1593            mine: self.flags.is_mine(),
1594            pending: self.flags.is_pending(),
1595            failed: self.flags.is_failed(),
1596            edited: self.is_edited(),
1597            npub: interner.resolve(self.npub_idx).map(|s| s.to_string()),
1598            replied_to: self.replied_to_hex(),
1599            replied_to_content: self.replied_to_content.as_ref().map(|s| s.to_string()),
1600            replied_to_npub: interner.resolve(self.replied_to_npub_idx).map(|s| s.to_string()),
1601            replied_to_has_attachment: self.flags.replied_to_has_attachment(),
1602            // Re-resolved per get_message_views / populate_reply_context; the compact
1603            // form keeps only the bool, so this stays None on the RAM path.
1604            replied_to_attachment_extension: None,
1605            wrapper_event_id: self.wrapper_id_hex(),
1606            content: self.content.to_string(),
1607            // Convert compact attachments back to regular Attachment
1608            attachments: self.attachments.iter()
1609                .map(|a| a.to_attachment())
1610                .collect(),
1611            // Convert compact reactions back to regular Reaction
1612            reactions: self.reactions.iter()
1613                .map(|r| r.to_reaction(interner))
1614                .collect(),
1615            // Unbox rare fields
1616            edit_history: self.edit_history.as_ref().map(|b| (**b).clone()),
1617            preview_metadata: self.preview_metadata.as_ref().map(|b| (**b).clone()),
1618            emoji_tags: self.emoji_tags.as_ref().map(|b| (**b).clone()).unwrap_or_default(),
1619            addressed_bots: self
1620                .addressed_bots
1621                .as_ref()
1622                .map(|b| b.iter().filter_map(|&i| interner.resolve(i).map(|s| s.to_string())).collect())
1623                .unwrap_or_default(),
1624        }
1625    }
1626}
1627
1628#[cfg(test)]
1629mod tests {
1630    use super::*;
1631
1632    // A real event id that happens to start with the pending marker byte must
1633    // round-trip EXACTLY — the marker alone once misread 1 in 256 real ids as
1634    // phantom "pending-…" strings, wedging read markers among other consumers.
1635    #[test]
1636    fn real_id_starting_with_marker_byte_roundtrips() {
1637        let id = "01b95c05179c6d6abbc60b9a35198fdee6e0ce5b80a0b7ac8d465d81d038a73d";
1638        let encoded = encode_message_id(id);
1639        assert_eq!(decode_message_id(&encoded), id);
1640    }
1641
1642    #[test]
1643    fn pending_id_roundtrips() {
1644        let id = "pending-306878031314";
1645        let encoded = encode_message_id(id);
1646        assert_eq!(encoded[0], PENDING_ID_MARKER);
1647        assert_eq!(decode_message_id(&encoded), id);
1648    }
1649
1650    #[test]
1651    fn marker_byte_without_sentinel_decodes_as_hex() {
1652        // Marker byte + arbitrary tail (no sentinel) is a real id, not pending.
1653        let mut bytes = [0xABu8; 32];
1654        bytes[0] = PENDING_ID_MARKER;
1655        let decoded = decode_message_id(&bytes);
1656        assert!(!decoded.starts_with("pending-"));
1657        assert_eq!(decoded.len(), 64);
1658    }
1659
1660    #[test]
1661    fn test_message_flags() {
1662        let mut flags = MessageFlags::NONE;
1663        assert!(!flags.is_mine());
1664        assert!(!flags.is_pending());
1665        assert!(!flags.is_failed());
1666
1667        flags.set_mine(true);
1668        assert!(flags.is_mine());
1669
1670        flags.set_pending(true);
1671        assert!(flags.is_pending());
1672        assert!(flags.is_mine()); // Still set
1673
1674        flags.set_mine(false);
1675        assert!(!flags.is_mine());
1676        assert!(flags.is_pending()); // Still set
1677    }
1678
1679    #[test]
1680    fn test_npub_interner() {
1681        let mut interner = NpubInterner::new();
1682
1683        let idx1 = interner.intern("npub1alice");
1684        let idx2 = interner.intern("npub1bob");
1685        let idx3 = interner.intern("npub1alice"); // Duplicate
1686
1687        assert_eq!(idx1, idx3); // Same string = same index
1688        assert_ne!(idx1, idx2);
1689
1690        assert_eq!(interner.resolve(idx1), Some("npub1alice"));
1691        assert_eq!(interner.resolve(idx2), Some("npub1bob"));
1692        assert_eq!(interner.resolve(NO_NPUB), None);
1693    }
1694
1695    #[test]
1696    fn test_compact_message_vec_insert_and_find() {
1697        let mut vec = CompactMessageVec::new();
1698        let mut interner = NpubInterner::new();
1699
1700        let msg1 = CompactMessage {
1701            id: hex_to_bytes_32("0000000000000000000000000000000000000000000000000000000000000001"),
1702            at: 1000,
1703            expiration_secs: 0,
1704            flags: MessageFlags::NONE,
1705            npub_idx: interner.intern("npub1test"),
1706            replied_to: None,
1707            replied_to_npub_idx: NO_NPUB,
1708            wrapper_id: None,
1709            content: "First message".to_string().into_boxed_str(),
1710            replied_to_content: None,
1711            attachments: TinyVec::new(),
1712            reactions: TinyVec::new(),
1713            edit_history: None,
1714            preview_metadata: None,  // Boxed, but None = 8 bytes
1715            emoji_tags: None,
1716            addressed_bots: None,
1717        };
1718
1719        let msg2 = CompactMessage {
1720            id: hex_to_bytes_32("0000000000000000000000000000000000000000000000000000000000000002"),
1721            at: 2000,
1722            expiration_secs: 0,
1723            flags: MessageFlags::MINE,
1724            npub_idx: interner.intern("npub1me"),
1725            replied_to: None,
1726            replied_to_npub_idx: NO_NPUB,
1727            wrapper_id: None,
1728            content: "Second message".to_string().into_boxed_str(),
1729            replied_to_content: None,
1730            attachments: TinyVec::new(),
1731            reactions: TinyVec::new(),
1732            edit_history: None,
1733            preview_metadata: None,  // Boxed, but None = 8 bytes
1734            emoji_tags: None,
1735            addressed_bots: None,
1736        };
1737
1738        assert!(vec.insert(msg1));
1739        assert!(vec.insert(msg2));
1740        assert_eq!(vec.len(), 2);
1741
1742        // Find by ID
1743        let found = vec.find_by_hex_id("0000000000000000000000000000000000000000000000000000000000000001");
1744        assert!(found.is_some());
1745        assert_eq!(&*found.unwrap().content, "First message");
1746
1747        // Find non-existent
1748        let not_found = vec.find_by_hex_id("0000000000000000000000000000000000000000000000000000000000000099");
1749        assert!(not_found.is_none());
1750    }
1751
1752    #[test]
1753    fn test_duplicate_insert_rejected() {
1754        let mut vec = CompactMessageVec::new();
1755
1756        let msg = CompactMessage {
1757            id: hex_to_bytes_32("abcd000000000000000000000000000000000000000000000000000000000000"),
1758            at: 1000,
1759            expiration_secs: 0,
1760            flags: MessageFlags::NONE,
1761            npub_idx: NO_NPUB,
1762            replied_to: None,
1763            replied_to_npub_idx: NO_NPUB,
1764            wrapper_id: None,
1765            content: "Test".to_string().into_boxed_str(),
1766            replied_to_content: None,
1767            attachments: TinyVec::new(),
1768            reactions: TinyVec::new(),
1769            edit_history: None,
1770            preview_metadata: None,  // Boxed
1771            emoji_tags: None,
1772            addressed_bots: None,
1773        };
1774
1775        assert!(vec.insert(msg.clone()));
1776        assert!(!vec.insert(msg)); // Duplicate rejected
1777        assert_eq!(vec.len(), 1);
1778    }
1779
1780    /// Comprehensive benchmark test for memory reduction and performance
1781    #[test]
1782    fn benchmark_compact_vs_message() {
1783        use std::time::Instant;
1784
1785        const NUM_MESSAGES: usize = 10_000;
1786        const NUM_UNIQUE_USERS: usize = 50; // Realistic chat scenario
1787
1788        println!("\n========================================");
1789        println!("  COMPACT MESSAGE BENCHMARK");
1790        println!("  {} messages, {} unique users", NUM_MESSAGES, NUM_UNIQUE_USERS);
1791        println!("========================================\n");
1792
1793        // Generate test data
1794        let users: Vec<String> = (0..NUM_UNIQUE_USERS)
1795            .map(|i| format!("npub1{:0>62}", i))
1796            .collect();
1797
1798        // Create regular Messages
1799        let messages: Vec<Message> = (0..NUM_MESSAGES)
1800            .map(|i| {
1801                let user_idx = i % NUM_UNIQUE_USERS;
1802                Message {
1803                    expiration: None,
1804                    id: format!("{:0>64x}", i),
1805                    at: 1700000000000 + (i as u64 * 1000),
1806                    mine: user_idx == 0,
1807                    pending: false,
1808                    failed: false,
1809                    edited: false,
1810                    npub: Some(users[user_idx].clone()),
1811                    replied_to: if i > 0 && i % 5 == 0 {
1812                        format!("{:0>64x}", i - 1)
1813                    } else {
1814                        String::new()
1815                    },
1816                    replied_to_content: if i > 0 && i % 5 == 0 {
1817                        Some("Previous message content".to_string())
1818                    } else {
1819                        None
1820                    },
1821                    replied_to_npub: if i > 0 && i % 5 == 0 {
1822                        Some(users[(i - 1) % NUM_UNIQUE_USERS].clone())
1823                    } else {
1824                        None
1825                    },
1826                    replied_to_has_attachment: None,
1827                    replied_to_attachment_extension: None,
1828                    wrapper_event_id: Some(format!("{:0>64x}", i + 1000000)),
1829                    content: format!("This is message number {} with some typical content length.", i),
1830                    attachments: vec![],
1831                    reactions: vec![],
1832                    edit_history: None,
1833                    preview_metadata: None,
1834                    emoji_tags: Vec::new(),
1835                    addressed_bots: Vec::new(),
1836                }
1837            })
1838            .collect();
1839
1840        // ===== MEMORY COMPARISON =====
1841        println!("--- STRUCT SIZES ---");
1842        println!("  Message struct:        {} bytes", std::mem::size_of::<Message>());
1843        println!("  CompactMessage struct: {} bytes", std::mem::size_of::<CompactMessage>());
1844        println!("  Savings per struct:    {} bytes ({:.1}%)",
1845            std::mem::size_of::<Message>().saturating_sub(std::mem::size_of::<CompactMessage>()),
1846            (1.0 - std::mem::size_of::<CompactMessage>() as f64 / std::mem::size_of::<Message>() as f64) * 100.0
1847        );
1848        println!();
1849
1850        // Measure Message storage (simulating Vec<Message>)
1851        let msg_heap_estimate: usize = messages.iter().map(|m| {
1852            m.id.capacity()
1853                + m.npub.as_ref().map(|s| s.capacity()).unwrap_or(0)
1854                + m.replied_to.capacity()
1855                + m.replied_to_content.as_ref().map(|s| s.capacity()).unwrap_or(0)
1856                + m.replied_to_npub.as_ref().map(|s| s.capacity()).unwrap_or(0)
1857                + m.wrapper_event_id.as_ref().map(|s| s.capacity()).unwrap_or(0)
1858                + m.content.capacity()
1859        }).sum();
1860        let msg_total = messages.len() * std::mem::size_of::<Message>() + msg_heap_estimate;
1861
1862        // ===== CONVERSION + INSERT BENCHMARK =====
1863        println!("--- INSERT BENCHMARK ---");
1864
1865        // Test 1: Sequential inserts (simulates real-time message arrival)
1866        let mut interner = NpubInterner::with_capacity(NUM_UNIQUE_USERS);
1867        let mut compact_vec = CompactMessageVec::with_capacity(NUM_MESSAGES);
1868
1869        let insert_start = Instant::now();
1870        for msg in &messages {
1871            let compact = CompactMessage::from_message(msg, &mut interner);
1872            compact_vec.insert(compact);
1873        }
1874        let insert_elapsed = insert_start.elapsed();
1875
1876        println!("  Sequential insert (optimized append path):");
1877        println!("    {} messages in {:?}", NUM_MESSAGES, insert_elapsed);
1878        println!("    Rate: {:.0} msgs/sec", NUM_MESSAGES as f64 / insert_elapsed.as_secs_f64());
1879        println!("    Per message: {:.3} us ({} ns)",
1880            insert_elapsed.as_micros() as f64 / NUM_MESSAGES as f64,
1881            insert_elapsed.as_nanos() / NUM_MESSAGES as u128);
1882        println!();
1883
1884        // Test 2: Batch insert (simulates pagination/history loading)
1885        let mut interner2 = NpubInterner::with_capacity(NUM_UNIQUE_USERS);
1886        let mut compact_vec2 = CompactMessageVec::with_capacity(NUM_MESSAGES);
1887
1888        let batch_start = Instant::now();
1889        let compact_messages: Vec<_> = messages.iter()
1890            .map(|msg| CompactMessage::from_message(msg, &mut interner2))
1891            .collect();
1892        let batch_added = compact_vec2.insert_batch(compact_messages);
1893        let batch_elapsed = batch_start.elapsed();
1894
1895        println!("  Batch insert (pagination/history load):");
1896        println!("    {} messages in {:?}", batch_added, batch_elapsed);
1897        println!("    Rate: {:.0} msgs/sec", NUM_MESSAGES as f64 / batch_elapsed.as_secs_f64());
1898        println!("    Per message: {:.3} us ({} ns)",
1899            batch_elapsed.as_micros() as f64 / NUM_MESSAGES as f64,
1900            batch_elapsed.as_nanos() / NUM_MESSAGES as u128);
1901        println!();
1902
1903        // ===== COMPACT MEMORY USAGE =====
1904        println!("--- MEMORY COMPARISON ---");
1905        let compact_heap_estimate: usize = compact_vec.iter().map(|m| {
1906            m.content.len()  // Box<str> has no capacity, just len
1907                + m.replied_to_content.as_ref().map(|s| s.len()).unwrap_or(0)
1908                + m.attachments.len() * std::mem::size_of::<Attachment>() + if m.attachments.is_empty() { 0 } else { 1 }
1909                + m.reactions.len() * std::mem::size_of::<Reaction>() + if m.reactions.is_empty() { 0 } else { 1 }
1910        }).sum();
1911        let compact_struct_mem = compact_vec.len() * std::mem::size_of::<CompactMessage>();
1912        let compact_index_mem = compact_vec.len() * std::mem::size_of::<([u8; 32], u32)>();
1913        let interner_mem = interner.memory_usage();
1914        let compact_total = compact_struct_mem + compact_heap_estimate + compact_index_mem + interner_mem;
1915
1916        println!("  Regular Message storage:");
1917        println!("    Struct memory:     {:>10} bytes", messages.len() * std::mem::size_of::<Message>());
1918        println!("    Heap (strings):    {:>10} bytes", msg_heap_estimate);
1919        println!("    TOTAL:             {:>10} bytes ({:.2} MB)", msg_total, msg_total as f64 / 1_000_000.0);
1920        println!();
1921        println!("  CompactMessage storage:");
1922        println!("    Struct memory:     {:>10} bytes", compact_struct_mem);
1923        println!("    Heap (strings):    {:>10} bytes", compact_heap_estimate);
1924        println!("    ID index:          {:>10} bytes", compact_index_mem);
1925        println!("    Interner:          {:>10} bytes ({} unique npubs)", interner_mem, interner.len());
1926        println!("    TOTAL:             {:>10} bytes ({:.2} MB)", compact_total, compact_total as f64 / 1_000_000.0);
1927        println!();
1928        println!("  SAVINGS: {} bytes ({:.1}%)",
1929            msg_total.saturating_sub(compact_total),
1930            (1.0 - compact_total as f64 / msg_total as f64) * 100.0
1931        );
1932        println!("  Per message: {} -> {} bytes (avg)",
1933            msg_total / NUM_MESSAGES,
1934            compact_total / NUM_MESSAGES
1935        );
1936        println!();
1937
1938        // ===== LOOKUP BENCHMARK =====
1939        println!("--- LOOKUP BENCHMARK ---");
1940
1941        // Generate random lookup IDs (mix of existing and non-existing)
1942        let lookup_ids: Vec<String> = (0..1000)
1943            .map(|i| format!("{:0>64x}", i * 10)) // Every 10th message
1944            .collect();
1945
1946        // Benchmark binary search lookup (CompactMessageVec)
1947        let lookup_start = Instant::now();
1948        let mut found_count = 0;
1949        for _ in 0..100 { // 100 iterations
1950            for id in &lookup_ids {
1951                if compact_vec.find_by_hex_id(id).is_some() {
1952                    found_count += 1;
1953                }
1954            }
1955        }
1956        let lookup_elapsed = lookup_start.elapsed();
1957        let total_lookups = 100 * lookup_ids.len();
1958
1959        println!("  Binary search (CompactMessageVec):");
1960        println!("    {} lookups in {:?}", total_lookups, lookup_elapsed);
1961        println!("    Rate: {:.0} lookups/sec", total_lookups as f64 / lookup_elapsed.as_secs_f64());
1962        println!("    Per lookup: {:.2} us", lookup_elapsed.as_micros() as f64 / total_lookups as f64);
1963        println!("    Found: {} / {}", found_count, total_lookups);
1964        println!();
1965
1966        // Benchmark linear search (simulating Vec<Message>)
1967        let linear_start = Instant::now();
1968        let mut linear_found = 0;
1969        for _ in 0..100 {
1970            for id in &lookup_ids {
1971                if messages.iter().find(|m| &m.id == id).is_some() {
1972                    linear_found += 1;
1973                }
1974            }
1975        }
1976        let linear_elapsed = linear_start.elapsed();
1977
1978        println!("  Linear search (Vec<Message>):");
1979        println!("    {} lookups in {:?}", total_lookups, linear_elapsed);
1980        println!("    Rate: {:.0} lookups/sec", total_lookups as f64 / linear_elapsed.as_secs_f64());
1981        println!("    Per lookup: {:.2} us", linear_elapsed.as_micros() as f64 / total_lookups as f64);
1982        println!();
1983
1984        let speedup = linear_elapsed.as_nanos() as f64 / lookup_elapsed.as_nanos() as f64;
1985        println!("  SPEEDUP: {:.1}x faster with binary search!", speedup);
1986        println!();
1987
1988        // ===== INTERNER EFFICIENCY =====
1989        println!("--- INTERNER EFFICIENCY ---");
1990        let npub_string_size = 63 + 1; // "npub1" + 58 chars + null
1991        let naive_npub_mem = NUM_MESSAGES * npub_string_size * 2; // npub + replied_to_npub
1992        let actual_npub_mem = interner_mem;
1993        println!("  Naive (every msg stores npubs): {} bytes", naive_npub_mem);
1994        println!("  Interned ({} unique):           {} bytes", interner.len(), actual_npub_mem);
1995        println!("  SAVINGS: {} bytes ({:.1}%)",
1996            naive_npub_mem.saturating_sub(actual_npub_mem),
1997            (1.0 - actual_npub_mem as f64 / naive_npub_mem as f64) * 100.0
1998        );
1999        println!();
2000
2001        println!("========================================");
2002        println!("  BENCHMARK COMPLETE");
2003        println!("========================================\n");
2004
2005        // Verify correctness
2006        assert_eq!(compact_vec.len(), NUM_MESSAGES);
2007        assert_eq!(interner.len(), NUM_UNIQUE_USERS);
2008        assert_eq!(found_count, linear_found);
2009    }
2010
2011    /// Benchmark: Profile lookup -- linear scan vs string binary search vs handle binary search
2012    ///
2013    /// Compares three approaches for finding a Profile in a Vec:
2014    /// 1. Linear scan with string equality (old -- O(n) x 63-byte strcmp, Profile.id was String)
2015    /// 2. Binary search by npub string (intermediate -- O(log n) x 63-byte strcmp)
2016    /// 3. Direct u16 handle binary search (current -- O(log n) x 2-byte int cmp, Profile.id is u16)
2017    #[test]
2018    fn benchmark_profile_lookup() {
2019        use std::time::Instant;
2020        use std::hint::black_box;
2021
2022        const NUM_PROFILES: usize = 60;
2023        const NUM_LOOKUPS: usize = 100_000;
2024
2025        println!("\n========================================");
2026        println!("  PROFILE LOOKUP BENCHMARK");
2027        println!("  {} profiles, {} lookups each method", NUM_PROFILES, NUM_LOOKUPS);
2028        println!("========================================\n");
2029
2030        // Generate realistic npubs (63 chars each: "npub1" + 58 hex-like chars)
2031        let npubs: Vec<String> = (0..NUM_PROFILES)
2032            .map(|i| format!("npub1{:0>58}", format!("{:x}", i * 7919 + 1000))) // spread out values
2033            .collect();
2034
2035        // --- Setup: Method 1 - Linear scan (old approach, simulating id: String) ---
2036        let old_ids: Vec<String> = npubs.iter().rev().cloned().collect(); // reversed = worst case
2037
2038        // --- Setup: Method 2 - String binary search (intermediate approach) ---
2039        let mut sorted_ids: Vec<String> = npubs.clone();
2040        sorted_ids.sort();
2041
2042        // --- Setup: Method 3 - Direct u16 handle lookup (current approach, id: u16) ---
2043        let mut interner = NpubInterner::new();
2044        let mut profiles: Vec<crate::profile::Profile> = npubs.iter().map(|npub| {
2045            let mut p = crate::profile::Profile::new();
2046            p.id = interner.intern(npub);
2047            p
2048        }).collect();
2049        profiles.sort_by(|a, b| a.id.cmp(&b.id));
2050
2051        // Build lookup targets: cycle through all profiles
2052        let lookup_targets: Vec<&str> = (0..NUM_LOOKUPS)
2053            .map(|i| npubs[i % NUM_PROFILES].as_str())
2054            .collect();
2055
2056        // Pre-resolve handles for method 3
2057        let handle_targets: Vec<u16> = lookup_targets.iter()
2058            .map(|&npub| interner.lookup(npub).unwrap())
2059            .collect();
2060
2061        // ===== BENCHMARK 1: Linear scan (old -- id: String) =====
2062        let start = Instant::now();
2063        let mut found = 0u64;
2064        for &target in &lookup_targets {
2065            if old_ids.iter().any(|id| id == target) {
2066                found += 1;
2067            }
2068        }
2069        let linear_elapsed = start.elapsed();
2070        assert_eq!(found, NUM_LOOKUPS as u64);
2071
2072        // ===== BENCHMARK 2: String binary search (intermediate) =====
2073        let start = Instant::now();
2074        found = 0;
2075        for &target in &lookup_targets {
2076            if sorted_ids.binary_search_by(|id| id.as_str().cmp(target)).is_ok() {
2077                found += 1;
2078            }
2079        }
2080        let string_bs_elapsed = start.elapsed();
2081        assert_eq!(found, NUM_LOOKUPS as u64);
2082
2083        // ===== BENCHMARK 3: Direct u16 handle lookup (current -- id: u16) =====
2084        let start = Instant::now();
2085        found = 0;
2086        for &handle in &handle_targets {
2087            if profiles.binary_search_by(|p| p.id.cmp(black_box(&handle))).is_ok() {
2088                found += 1;
2089            }
2090        }
2091        let direct_elapsed = start.elapsed();
2092        assert_eq!(found, NUM_LOOKUPS as u64);
2093
2094        // ===== RESULTS =====
2095        println!("--- LOOKUP METHODS ---");
2096        println!("  1. Linear scan (old id: String):");
2097        println!("     {:?} total, {:.0} ns/lookup",
2098            linear_elapsed,
2099            linear_elapsed.as_nanos() as f64 / NUM_LOOKUPS as f64);
2100        println!();
2101        println!("  2. String binary search (intermediate):");
2102        println!("     {:?} total, {:.0} ns/lookup",
2103            string_bs_elapsed,
2104            string_bs_elapsed.as_nanos() as f64 / NUM_LOOKUPS as f64);
2105        println!("     vs linear: {:.1}x faster",
2106            linear_elapsed.as_nanos() as f64 / string_bs_elapsed.as_nanos() as f64);
2107        println!();
2108        println!("  3. Direct u16 handle lookup (current id: u16):");
2109        println!("     {:?} total, {:.0} ns/lookup",
2110            direct_elapsed,
2111            direct_elapsed.as_nanos() as f64 / NUM_LOOKUPS as f64);
2112        println!("     vs linear: {:.1}x faster",
2113            linear_elapsed.as_nanos() as f64 / direct_elapsed.as_nanos() as f64);
2114        println!("     vs string BS: {:.1}x faster",
2115            string_bs_elapsed.as_nanos() as f64 / direct_elapsed.as_nanos() as f64);
2116        println!();
2117
2118        // ===== MEMORY COMPARISON =====
2119        println!("--- MEMORY PER PROFILE ---");
2120        println!("  Old (id: String):    ~87 bytes (24 String header + ~63 heap)");
2121        println!("  Current (id: u16):     2 bytes (inline)");
2122        println!("  Savings: ~85 bytes/profile, ~{} bytes for {} profiles",
2123            85 * NUM_PROFILES, NUM_PROFILES);
2124        println!("  Interner (shared):   {} bytes (shared with message system)",
2125            interner.memory_usage());
2126        println!();
2127
2128        println!("========================================");
2129        println!("  BENCHMARK COMPLETE");
2130        println!("========================================\n");
2131
2132        // Correctness: ensure all methods find the same profiles
2133        for npub in &npubs {
2134            assert!(old_ids.iter().any(|id| id == npub));
2135            assert!(sorted_ids.binary_search_by(|id| id.as_str().cmp(npub.as_str())).is_ok());
2136            let h = interner.lookup(npub).unwrap();
2137            assert!(profiles.binary_search_by(|p| p.id.cmp(&h)).is_ok());
2138        }
2139    }
2140
2141    // ========================================================================
2142    // Pending ID Encoding Tests
2143    // ========================================================================
2144
2145    #[test]
2146    fn pending_id_roundtrip_regular_hex() {
2147        let hex = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
2148        let encoded = encode_message_id(hex);
2149        let decoded = decode_message_id(&encoded);
2150        assert_eq!(decoded, hex, "regular hex ID should roundtrip exactly");
2151    }
2152
2153    #[test]
2154    fn pending_id_roundtrip_pending() {
2155        let id = "pending-1234567890123456789";
2156        let encoded = encode_message_id(id);
2157        let decoded = decode_message_id(&encoded);
2158        assert_eq!(decoded, id, "pending ID should roundtrip exactly");
2159    }
2160
2161    #[test]
2162    fn pending_id_zero_timestamp() {
2163        let id = "pending-0";
2164        let encoded = encode_message_id(id);
2165        assert_eq!(encoded[0], PENDING_ID_MARKER, "first byte should be marker");
2166        let decoded = decode_message_id(&encoded);
2167        assert_eq!(decoded, id, "pending-0 should roundtrip");
2168    }
2169
2170    #[test]
2171    fn pending_id_max_u128_timestamp() {
2172        let max = u128::MAX;
2173        let id = format!("pending-{}", max);
2174        let encoded = encode_message_id(&id);
2175        let decoded = decode_message_id(&encoded);
2176        assert_eq!(decoded, id, "pending with max u128 should roundtrip");
2177    }
2178
2179    #[test]
2180    fn pending_id_all_zero_hex() {
2181        let hex = "0000000000000000000000000000000000000000000000000000000000000000";
2182        let encoded = encode_message_id(hex);
2183        let decoded = decode_message_id(&encoded);
2184        assert_eq!(decoded, hex, "all-zero hex ID should roundtrip");
2185        // All-zero should NOT be detected as pending since byte 0 is 0x00, not 0x01
2186        assert_ne!(encoded[0], PENDING_ID_MARKER);
2187    }
2188
2189    #[test]
2190    fn pending_id_all_ff_hex() {
2191        let hex = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
2192        let encoded = encode_message_id(hex);
2193        let decoded = decode_message_id(&encoded);
2194        assert_eq!(decoded, hex, "all-ff hex ID should roundtrip");
2195        assert_eq!(encoded, [0xff; 32], "all-ff should decode to all 0xff bytes");
2196    }
2197
2198    #[test]
2199    fn pending_id_mixed_case_hex() {
2200        let lower = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
2201        let mixed = "ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789";
2202        let encoded_lower = encode_message_id(lower);
2203        let encoded_mixed = encode_message_id(mixed);
2204        assert_eq!(encoded_lower, encoded_mixed, "mixed case should produce same bytes as lowercase");
2205    }
2206
2207    #[test]
2208    fn pending_id_short_hex_partial_decode() {
2209        // SIMD hex_to_bytes_32 partially decodes short input (decodes what it can)
2210        let short = "abcdef";
2211        let encoded = encode_message_id(short);
2212        // Short input is decoded as far as possible, remaining bytes are zero
2213        assert_eq!(encoded[0..14], [0u8; 14], "leading bytes should be zero for short input");
2214    }
2215
2216    #[test]
2217    fn pending_id_marker_distinguishes_from_real_id() {
2218        // A real event ID starting with 0x01 must NOT be confused with pending:
2219        // 1 in 256 real ids begin with the marker byte, and misreading them
2220        // mangled read markers into phantom "pending-…" strings. Only the
2221        // marker + sentinel combination reads as pending.
2222        let hex = "0100000000000000000000000000000000000000000000000000000000000000";
2223        let encoded = encode_message_id(hex);
2224        let decoded = decode_message_id(&encoded);
2225        assert_eq!(decoded, hex, "marker-leading real id must roundtrip exactly");
2226    }
2227
2228    #[test]
2229    fn pending_id_large_timestamp() {
2230        let id = "pending-99999999999999999";
2231        let encoded = encode_message_id(&id);
2232        let decoded = decode_message_id(&encoded);
2233        assert_eq!(decoded, id, "large but valid timestamp should roundtrip");
2234    }
2235
2236    #[test]
2237    fn pending_id_invalid_timestamp_becomes_zero() {
2238        // If the timestamp part can't be parsed, it stays as all-zeros in bytes 1..17
2239        let id = "pending-notanumber";
2240        let encoded = encode_message_id(id);
2241        assert_eq!(encoded[0], PENDING_ID_MARKER);
2242        // Bytes 1..17 should all be 0
2243        assert_eq!(&encoded[1..17], &[0u8; 16]);
2244        let decoded = decode_message_id(&encoded);
2245        assert_eq!(decoded, "pending-0", "invalid timestamp parses as pending-0");
2246    }
2247
2248    // ========================================================================
2249    // Timestamp Tests
2250    // ========================================================================
2251
2252    #[test]
2253    fn timestamp_to_compact_and_back_roundtrip() {
2254        // A representative timestamp: 2024-01-15 12:00:00 UTC in milliseconds
2255        let ms: u64 = 1705320000000;
2256        let compact = timestamp_to_compact(ms);
2257        let restored = timestamp_from_compact(compact);
2258        // The sub-second part is lost (ms -> seconds -> ms), so restored is floored to seconds
2259        assert_eq!(restored / 1000, ms / 1000, "roundtrip should preserve seconds");
2260    }
2261
2262    #[test]
2263    fn secs_to_compact_and_back_roundtrip() {
2264        let secs: u64 = 1705320000; // 2024-01-15 12:00:00 UTC
2265        let compact = secs_to_compact(secs);
2266        let restored = secs_from_compact(compact);
2267        assert_eq!(restored, secs, "secs should roundtrip exactly");
2268    }
2269
2270    #[test]
2271    fn timestamp_zero_preservation() {
2272        // Zero is a sentinel for "never set" in secs_to_compact/secs_from_compact
2273        assert_eq!(secs_to_compact(0), 0, "zero secs should produce zero compact");
2274        assert_eq!(secs_from_compact(0), 0, "zero compact should produce zero secs");
2275    }
2276
2277    #[test]
2278    fn timestamp_epoch_boundary() {
2279        // Exactly 2020-01-01 00:00:00 UTC = EPOCH_2020_SECS
2280        let epoch_secs: u64 = 1577836800;
2281        let compact = secs_to_compact(epoch_secs);
2282        assert_eq!(compact, 0, "epoch boundary should map to compact 0");
2283        let restored = secs_from_compact(compact);
2284        // secs_from_compact(0) returns 0 (sentinel), not epoch
2285        assert_eq!(restored, 0, "compact 0 returns sentinel 0");
2286    }
2287
2288    #[test]
2289    fn timestamp_epoch_boundary_ms() {
2290        let epoch_ms: u64 = 1577836800000;
2291        let compact = timestamp_to_compact(epoch_ms);
2292        assert_eq!(compact, epoch_ms, "full u64 — identity function");
2293        let restored = timestamp_from_compact(compact);
2294        assert_eq!(restored, epoch_ms, "roundtrip preserves value");
2295    }
2296
2297    #[test]
2298    fn timestamp_current_time_roundtrip() {
2299        // Simulate a current-ish timestamp: 2026-03-27 in seconds
2300        let secs: u64 = 1774800000;
2301        let compact = secs_to_compact(secs);
2302        let restored = secs_from_compact(compact);
2303        assert_eq!(restored, secs, "current-era timestamp should roundtrip");
2304        assert!(compact > 0, "current time should be past epoch");
2305    }
2306
2307    #[test]
2308    fn timestamp_far_future_year_2100() {
2309        // 2100-01-01 00:00:00 UTC
2310        let secs: u64 = 4102444800;
2311        let compact = secs_to_compact(secs);
2312        let restored = secs_from_compact(compact);
2313        assert_eq!(restored, secs, "year 2100 should roundtrip");
2314        // Verify it fits in u32
2315        assert!(compact <= u32::MAX, "year 2100 should fit in u32");
2316    }
2317
2318    #[test]
2319    fn timestamp_pre_epoch_saturates_to_zero() {
2320        // A timestamp before 2020 epoch
2321        let secs: u64 = 1500000000; // ~2017
2322        let compact = secs_to_compact(secs);
2323        // saturating_sub means this becomes 0
2324        assert_eq!(compact, 0, "pre-epoch timestamp should saturate to 0");
2325    }
2326
2327    #[test]
2328    fn timestamp_ms_sub_second_precision_preserved() {
2329        let ms: u64 = 1705320000999;
2330        let compact = timestamp_to_compact(ms);
2331        let restored = timestamp_from_compact(compact);
2332        assert_eq!(restored, ms, "sub-second ms must be preserved");
2333    }
2334
2335    #[test]
2336    fn timestamp_one_second_after_epoch() {
2337        let secs: u64 = 1577836801; // one second after epoch
2338        let compact = secs_to_compact(secs);
2339        assert_eq!(compact, 1, "one second after epoch should be compact 1");
2340        let restored = secs_from_compact(compact);
2341        assert_eq!(restored, secs, "should restore to original");
2342    }
2343
2344    // ========================================================================
2345    // MessageFlags Tests
2346    // ========================================================================
2347
2348    #[test]
2349    fn message_flags_mine_independent() {
2350        let mut flags = MessageFlags::NONE;
2351        flags.set_mine(true);
2352        assert!(flags.is_mine(), "mine should be set");
2353        assert!(!flags.is_pending(), "pending should not be set");
2354        assert!(!flags.is_failed(), "failed should not be set");
2355    }
2356
2357    #[test]
2358    fn message_flags_pending_independent() {
2359        let mut flags = MessageFlags::NONE;
2360        flags.set_pending(true);
2361        assert!(!flags.is_mine(), "mine should not be set");
2362        assert!(flags.is_pending(), "pending should be set");
2363        assert!(!flags.is_failed(), "failed should not be set");
2364    }
2365
2366    #[test]
2367    fn message_flags_failed_independent() {
2368        let mut flags = MessageFlags::NONE;
2369        flags.set_failed(true);
2370        assert!(!flags.is_mine(), "mine should not be set");
2371        assert!(!flags.is_pending(), "pending should not be set");
2372        assert!(flags.is_failed(), "failed should be set");
2373    }
2374
2375    #[test]
2376    fn message_flags_from_bools_all_false() {
2377        let flags = MessageFlags::from_bools(false, false, false);
2378        assert!(!flags.is_mine());
2379        assert!(!flags.is_pending());
2380        assert!(!flags.is_failed());
2381        assert_eq!(flags, MessageFlags::NONE, "all false should equal NONE");
2382    }
2383
2384    #[test]
2385    fn message_flags_from_bools_all_true() {
2386        let flags = MessageFlags::from_bools(true, true, true);
2387        assert!(flags.is_mine(), "mine should be set");
2388        assert!(flags.is_pending(), "pending should be set");
2389        assert!(flags.is_failed(), "failed should be set");
2390    }
2391
2392    #[test]
2393    fn message_flags_from_bools_various_combos() {
2394        let flags = MessageFlags::from_bools(true, false, true);
2395        assert!(flags.is_mine());
2396        assert!(!flags.is_pending());
2397        assert!(flags.is_failed());
2398
2399        let flags = MessageFlags::from_bools(false, true, false);
2400        assert!(!flags.is_mine());
2401        assert!(flags.is_pending());
2402        assert!(!flags.is_failed());
2403    }
2404
2405    #[test]
2406    fn message_flags_from_all_replied_to_none() {
2407        let flags = MessageFlags::from_all(false, false, false, None);
2408        assert_eq!(flags.replied_to_has_attachment(), None, "None should roundtrip");
2409    }
2410
2411    #[test]
2412    fn message_flags_from_all_replied_to_some_false() {
2413        let flags = MessageFlags::from_all(false, false, false, Some(false));
2414        assert_eq!(flags.replied_to_has_attachment(), Some(false), "Some(false) should roundtrip");
2415    }
2416
2417    #[test]
2418    fn message_flags_from_all_replied_to_some_true() {
2419        let flags = MessageFlags::from_all(false, false, false, Some(true));
2420        assert_eq!(flags.replied_to_has_attachment(), Some(true), "Some(true) should roundtrip");
2421    }
2422
2423    #[test]
2424    fn message_flags_multiple_set_simultaneously() {
2425        let flags = MessageFlags::from_all(true, true, false, Some(true));
2426        assert!(flags.is_mine());
2427        assert!(flags.is_pending());
2428        assert!(!flags.is_failed());
2429        assert_eq!(flags.replied_to_has_attachment(), Some(true));
2430    }
2431
2432    #[test]
2433    fn message_flags_default_is_all_false() {
2434        let flags = MessageFlags::default();
2435        assert!(!flags.is_mine());
2436        assert!(!flags.is_pending());
2437        assert!(!flags.is_failed());
2438        assert_eq!(flags.replied_to_has_attachment(), None);
2439        assert_eq!(flags, MessageFlags::NONE);
2440    }
2441
2442    #[test]
2443    fn message_flags_bit_patterns_correct() {
2444        assert_eq!(MessageFlags::MINE.0, 0b00001, "MINE bit pattern");
2445        assert_eq!(MessageFlags::PENDING.0, 0b00010, "PENDING bit pattern");
2446        assert_eq!(MessageFlags::FAILED.0, 0b00100, "FAILED bit pattern");
2447    }
2448
2449    #[test]
2450    fn message_flags_set_then_clear() {
2451        let mut flags = MessageFlags::from_bools(true, true, true);
2452        flags.set_mine(false);
2453        assert!(!flags.is_mine(), "mine should be cleared");
2454        assert!(flags.is_pending(), "pending should remain set");
2455        assert!(flags.is_failed(), "failed should remain set");
2456    }
2457
2458    #[test]
2459    fn message_flags_replied_to_overwrite() {
2460        let mut flags = MessageFlags::from_all(false, false, false, Some(true));
2461        assert_eq!(flags.replied_to_has_attachment(), Some(true));
2462        flags.set_replied_to_has_attachment(Some(false));
2463        assert_eq!(flags.replied_to_has_attachment(), Some(false), "overwrite should work");
2464        flags.set_replied_to_has_attachment(None);
2465        assert_eq!(flags.replied_to_has_attachment(), None, "clearing to None should work");
2466    }
2467
2468    #[test]
2469    fn message_flags_replied_to_does_not_interfere_with_other_bits() {
2470        let mut flags = MessageFlags::from_bools(true, true, true);
2471        flags.set_replied_to_has_attachment(Some(true));
2472        assert!(flags.is_mine(), "mine should still be set");
2473        assert!(flags.is_pending(), "pending should still be set");
2474        assert!(flags.is_failed(), "failed should still be set");
2475        assert_eq!(flags.replied_to_has_attachment(), Some(true));
2476    }
2477
2478    // ========================================================================
2479    // AttachmentFlags Tests
2480    // ========================================================================
2481
2482    #[test]
2483    fn attachment_flags_downloading_independent() {
2484        let mut flags = AttachmentFlags::NONE;
2485        flags.set_downloading(true);
2486        assert!(flags.is_downloading());
2487        assert!(!flags.is_downloaded());
2488        assert!(!flags.is_short_nonce());
2489    }
2490
2491    #[test]
2492    fn attachment_flags_downloaded_independent() {
2493        let mut flags = AttachmentFlags::NONE;
2494        flags.set_downloaded(true);
2495        assert!(!flags.is_downloading());
2496        assert!(flags.is_downloaded());
2497        assert!(!flags.is_short_nonce());
2498    }
2499
2500    #[test]
2501    fn attachment_flags_short_nonce_independent() {
2502        let mut flags = AttachmentFlags::NONE;
2503        flags.set_short_nonce(true);
2504        assert!(!flags.is_downloading());
2505        assert!(!flags.is_downloaded());
2506        assert!(flags.is_short_nonce());
2507    }
2508
2509    #[test]
2510    fn attachment_flags_from_bools() {
2511        let flags = AttachmentFlags::from_bools(true, false);
2512        assert!(flags.is_downloading());
2513        assert!(!flags.is_downloaded());
2514
2515        let flags = AttachmentFlags::from_bools(false, true);
2516        assert!(!flags.is_downloading());
2517        assert!(flags.is_downloaded());
2518    }
2519
2520    #[test]
2521    fn attachment_flags_all_set() {
2522        let mut flags = AttachmentFlags::NONE;
2523        flags.set_downloading(true);
2524        flags.set_downloaded(true);
2525        flags.set_short_nonce(true);
2526        assert!(flags.is_downloading());
2527        assert!(flags.is_downloaded());
2528        assert!(flags.is_short_nonce());
2529    }
2530
2531    #[test]
2532    fn attachment_flags_set_then_clear() {
2533        let mut flags = AttachmentFlags::NONE;
2534        flags.set_downloading(true);
2535        flags.set_downloaded(true);
2536        flags.set_downloading(false);
2537        assert!(!flags.is_downloading(), "downloading should be cleared");
2538        assert!(flags.is_downloaded(), "downloaded should remain set");
2539    }
2540
2541    #[test]
2542    fn attachment_flags_default_none() {
2543        let flags = AttachmentFlags::NONE;
2544        assert!(!flags.is_downloading());
2545        assert!(!flags.is_downloaded());
2546        assert!(!flags.is_short_nonce());
2547        assert_eq!(flags, AttachmentFlags::default());
2548    }
2549
2550    #[test]
2551    fn attachment_flags_bit_values() {
2552        // Verify the bit constants are distinct
2553        let mut flags = AttachmentFlags::NONE;
2554        flags.set_downloading(true);
2555        assert_eq!(flags.0, 0b0001);
2556
2557        let mut flags = AttachmentFlags::NONE;
2558        flags.set_downloaded(true);
2559        assert_eq!(flags.0, 0b0010);
2560
2561        let mut flags = AttachmentFlags::NONE;
2562        flags.set_short_nonce(true);
2563        assert_eq!(flags.0, 0b0100);
2564    }
2565
2566    // ========================================================================
2567    // NpubInterner Tests
2568    // ========================================================================
2569
2570    #[test]
2571    fn interner_returns_incrementing_handles() {
2572        let mut interner = NpubInterner::new();
2573        let h0 = interner.intern("npub1aaa");
2574        let h1 = interner.intern("npub1bbb");
2575        let h2 = interner.intern("npub1ccc");
2576        assert_eq!(h0, 0, "first intern should be handle 0");
2577        assert_eq!(h1, 1, "second intern should be handle 1");
2578        assert_eq!(h2, 2, "third intern should be handle 2");
2579    }
2580
2581    #[test]
2582    fn interner_lookup_finds_interned() {
2583        let mut interner = NpubInterner::new();
2584        let h = interner.intern("npub1alice");
2585        let found = interner.lookup("npub1alice");
2586        assert_eq!(found, Some(h), "lookup should find interned string");
2587    }
2588
2589    #[test]
2590    fn interner_lookup_returns_none_for_unknown() {
2591        let interner = NpubInterner::new();
2592        assert_eq!(interner.lookup("npub1unknown"), None, "lookup on empty interner should be None");
2593    }
2594
2595    #[test]
2596    fn interner_lookup_returns_none_for_not_interned() {
2597        let mut interner = NpubInterner::new();
2598        interner.intern("npub1alice");
2599        assert_eq!(interner.lookup("npub1bob"), None, "lookup for non-interned should be None");
2600    }
2601
2602    #[test]
2603    fn interner_resolve_returns_string() {
2604        let mut interner = NpubInterner::new();
2605        let h = interner.intern("npub1test123");
2606        assert_eq!(interner.resolve(h), Some("npub1test123"), "resolve should return the original string");
2607    }
2608
2609    #[test]
2610    fn interner_resolve_returns_none_for_no_npub() {
2611        let interner = NpubInterner::new();
2612        assert_eq!(interner.resolve(NO_NPUB), None, "resolve(NO_NPUB) should be None");
2613    }
2614
2615    #[test]
2616    fn interner_resolve_returns_none_for_out_of_bounds() {
2617        let mut interner = NpubInterner::new();
2618        interner.intern("npub1only");
2619        assert_eq!(interner.resolve(999), None, "out-of-bounds handle should resolve to None");
2620    }
2621
2622    #[test]
2623    fn interner_duplicate_returns_same_handle() {
2624        let mut interner = NpubInterner::new();
2625        let h1 = interner.intern("npub1dup");
2626        let h2 = interner.intern("npub1dup");
2627        let h3 = interner.intern("npub1dup");
2628        assert_eq!(h1, h2, "duplicate intern should return same handle");
2629        assert_eq!(h2, h3, "duplicate intern should return same handle");
2630        assert_eq!(interner.len(), 1, "duplicates should not increase length");
2631    }
2632
2633    #[test]
2634    fn interner_100_unique_npubs_stress() {
2635        let mut interner = NpubInterner::new();
2636        let mut handles = Vec::new();
2637
2638        for i in 0..100 {
2639            let npub = format!("npub1stress{:04}", i);
2640            let h = interner.intern(&npub);
2641            handles.push((h, npub));
2642        }
2643
2644        assert_eq!(interner.len(), 100, "should have 100 unique npubs");
2645
2646        // Verify all handles resolve correctly
2647        for (h, npub) in &handles {
2648            assert_eq!(interner.resolve(*h), Some(npub.as_str()),
2649                "handle {} should resolve to {}", h, npub);
2650        }
2651
2652        // Verify all lookups work
2653        for (h, npub) in &handles {
2654            assert_eq!(interner.lookup(npub), Some(*h),
2655                "lookup for {} should return handle {}", npub, h);
2656        }
2657
2658        // Re-interning should return same handles
2659        for (h, npub) in &handles {
2660            assert_eq!(interner.intern(npub), *h,
2661                "re-interning {} should return same handle {}", npub, h);
2662        }
2663        assert_eq!(interner.len(), 100, "re-interning should not grow interner");
2664    }
2665
2666    #[test]
2667    fn interner_memory_usage_reasonable() {
2668        let mut interner = NpubInterner::new();
2669        for i in 0..50 {
2670            interner.intern(&format!("npub1{:0>62}", i));
2671        }
2672        let mem = interner.memory_usage();
2673        // Should be in the ballpark of 50 * (64 bytes string + overhead)
2674        assert!(mem > 0, "memory usage should be positive");
2675        assert!(mem < 100_000, "memory usage for 50 npubs should be under 100KB, was {}", mem);
2676    }
2677
2678    #[test]
2679    fn interner_empty() {
2680        let interner = NpubInterner::new();
2681        assert_eq!(interner.len(), 0);
2682        assert!(interner.is_empty());
2683        assert_eq!(interner.resolve(0), None);
2684        assert_eq!(interner.lookup("anything"), None);
2685        assert!(interner.memory_usage() > 0, "even empty interner has struct overhead");
2686    }
2687
2688    #[test]
2689    fn interner_intern_opt_none() {
2690        let mut interner = NpubInterner::new();
2691        let h = interner.intern_opt(None);
2692        assert_eq!(h, NO_NPUB, "intern_opt(None) should return NO_NPUB");
2693        assert_eq!(interner.len(), 0, "None should not add to interner");
2694    }
2695
2696    #[test]
2697    fn interner_intern_opt_empty_string() {
2698        let mut interner = NpubInterner::new();
2699        let h = interner.intern_opt(Some(""));
2700        assert_eq!(h, NO_NPUB, "intern_opt(Some('')) should return NO_NPUB");
2701    }
2702
2703    #[test]
2704    fn interner_intern_opt_some_value() {
2705        let mut interner = NpubInterner::new();
2706        let h = interner.intern_opt(Some("npub1real"));
2707        assert_ne!(h, NO_NPUB, "intern_opt(Some(value)) should not return NO_NPUB");
2708        assert_eq!(interner.resolve(h), Some("npub1real"));
2709    }
2710
2711    // ========================================================================
2712    // CompactMessageVec Tests
2713    // ========================================================================
2714
2715    /// Helper to create a minimal CompactMessage with given hex ID and timestamp
2716    fn make_compact_msg(hex_id: &str, timestamp: u64) -> CompactMessage {
2717        CompactMessage {
2718            id: encode_message_id(hex_id),
2719            at: timestamp,
2720            expiration_secs: 0,
2721            flags: MessageFlags::NONE,
2722            npub_idx: NO_NPUB,
2723            replied_to: None,
2724            replied_to_npub_idx: NO_NPUB,
2725            wrapper_id: None,
2726            content: "test".into(),
2727            replied_to_content: None,
2728            attachments: TinyVec::new(),
2729            reactions: TinyVec::new(),
2730            edit_history: None,
2731            preview_metadata: None,
2732            emoji_tags: None,
2733            addressed_bots: None,
2734        }
2735    }
2736
2737    #[test]
2738    fn compact_vec_insert_single_message() {
2739        let mut vec = CompactMessageVec::new();
2740        let msg = make_compact_msg(
2741            "1111111111111111111111111111111111111111111111111111111111111111",
2742            100,
2743        );
2744        assert!(vec.insert(msg), "insert should succeed");
2745        assert_eq!(vec.len(), 1);
2746        assert!(!vec.is_empty());
2747    }
2748
2749    #[test]
2750    fn compact_vec_insert_duplicate_rejected() {
2751        let mut vec = CompactMessageVec::new();
2752        let id = "2222222222222222222222222222222222222222222222222222222222222222";
2753        let msg1 = make_compact_msg(id, 100);
2754        let msg2 = make_compact_msg(id, 200); // same ID, different timestamp
2755        assert!(vec.insert(msg1), "first insert should succeed");
2756        assert!(!vec.insert(msg2), "duplicate ID should be rejected");
2757        assert_eq!(vec.len(), 1);
2758    }
2759
2760    #[test]
2761    fn compact_vec_insert_batch_multiple() {
2762        let mut vec = CompactMessageVec::new();
2763        let msgs = vec![
2764            make_compact_msg("aa00000000000000000000000000000000000000000000000000000000000000", 100),
2765            make_compact_msg("bb00000000000000000000000000000000000000000000000000000000000000", 200),
2766            make_compact_msg("cc00000000000000000000000000000000000000000000000000000000000000", 300),
2767        ];
2768        let added = vec.insert_batch(msgs);
2769        assert_eq!(added, 3, "all 3 should be added");
2770        assert_eq!(vec.len(), 3);
2771    }
2772
2773    #[test]
2774    fn compact_vec_insert_batch_dedup() {
2775        let mut vec = CompactMessageVec::new();
2776        let id = "dd00000000000000000000000000000000000000000000000000000000000000";
2777        vec.insert(make_compact_msg(id, 100));
2778
2779        let msgs = vec![
2780            make_compact_msg(id, 200), // duplicate
2781            make_compact_msg("ee00000000000000000000000000000000000000000000000000000000000000", 300),
2782        ];
2783        let added = vec.insert_batch(msgs);
2784        assert_eq!(added, 1, "only non-duplicate should be added");
2785        assert_eq!(vec.len(), 2);
2786    }
2787
2788    #[test]
2789    fn compact_vec_find_by_hex_id() {
2790        let mut vec = CompactMessageVec::new();
2791        let id = "ff00000000000000000000000000000000000000000000000000000000000001";
2792        let mut msg = make_compact_msg(id, 500);
2793        msg.content = "found me".into();
2794        vec.insert(msg);
2795
2796        let found = vec.find_by_hex_id(id);
2797        assert!(found.is_some(), "should find by hex id");
2798        assert_eq!(&*found.unwrap().content, "found me");
2799    }
2800
2801    #[test]
2802    fn compact_vec_find_by_hex_id_not_found() {
2803        let mut vec = CompactMessageVec::new();
2804        vec.insert(make_compact_msg(
2805            "aa00000000000000000000000000000000000000000000000000000000000000", 100,
2806        ));
2807        let found = vec.find_by_hex_id(
2808            "bb00000000000000000000000000000000000000000000000000000000000000",
2809        );
2810        assert!(found.is_none(), "should not find non-existent ID");
2811    }
2812
2813    #[test]
2814    fn compact_vec_find_by_hex_id_empty_string() {
2815        let mut vec = CompactMessageVec::new();
2816        vec.insert(make_compact_msg(
2817            "aa00000000000000000000000000000000000000000000000000000000000000", 100,
2818        ));
2819        assert!(vec.find_by_hex_id("").is_none(), "empty string should return None");
2820    }
2821
2822    #[test]
2823    fn compact_vec_find_by_hex_id_mut() {
2824        let mut vec = CompactMessageVec::new();
2825        let id = "ff00000000000000000000000000000000000000000000000000000000000002";
2826        vec.insert(make_compact_msg(id, 500));
2827
2828        let found = vec.find_by_hex_id_mut(id);
2829        assert!(found.is_some(), "should find mutable ref by hex id");
2830        found.unwrap().content = "modified".into();
2831
2832        // Verify modification stuck
2833        let found = vec.find_by_hex_id(id);
2834        assert_eq!(&*found.unwrap().content, "modified");
2835    }
2836
2837    #[test]
2838    fn compact_vec_contains_hex_id() {
2839        let mut vec = CompactMessageVec::new();
2840        let id = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
2841        vec.insert(make_compact_msg(id, 100));
2842
2843        assert!(vec.contains_hex_id(id), "should contain inserted ID");
2844        assert!(!vec.contains_hex_id(
2845            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2846            "should not contain non-inserted ID");
2847        assert!(!vec.contains_hex_id(""), "empty string should not match");
2848    }
2849
2850    #[test]
2851    fn compact_vec_remove_by_hex_id() {
2852        let mut vec = CompactMessageVec::new();
2853        let id1 = "1100000000000000000000000000000000000000000000000000000000000000";
2854        let id2 = "2200000000000000000000000000000000000000000000000000000000000000";
2855        vec.insert(make_compact_msg(id1, 100));
2856        vec.insert(make_compact_msg(id2, 200));
2857        assert_eq!(vec.len(), 2);
2858
2859        assert!(vec.remove_by_hex_id(id1), "remove should succeed");
2860        assert_eq!(vec.len(), 1);
2861        assert!(!vec.contains_hex_id(id1), "removed ID should not be found");
2862        assert!(vec.contains_hex_id(id2), "remaining ID should still be found");
2863    }
2864
2865    #[test]
2866    fn compact_vec_remove_nonexistent() {
2867        let mut vec = CompactMessageVec::new();
2868        vec.insert(make_compact_msg(
2869            "1100000000000000000000000000000000000000000000000000000000000000", 100,
2870        ));
2871        assert!(!vec.remove_by_hex_id(
2872            "9900000000000000000000000000000000000000000000000000000000000000"),
2873            "removing non-existent should return false");
2874        assert!(!vec.remove_by_hex_id(""), "removing empty should return false");
2875        assert_eq!(vec.len(), 1);
2876    }
2877
2878    #[test]
2879    fn compact_vec_last_timestamp() {
2880        let mut vec = CompactMessageVec::new();
2881        assert_eq!(vec.last_timestamp(), None, "empty vec should have no last timestamp");
2882
2883        vec.insert(make_compact_msg(
2884            "aa00000000000000000000000000000000000000000000000000000000000000", 100,
2885        ));
2886        vec.insert(make_compact_msg(
2887            "bb00000000000000000000000000000000000000000000000000000000000000", 300,
2888        ));
2889        vec.insert(make_compact_msg(
2890            "cc00000000000000000000000000000000000000000000000000000000000000", 200,
2891        ));
2892
2893        let last_ts = vec.last_timestamp().unwrap();
2894        // Messages are sorted by timestamp, so last should be the one with at=300
2895        let expected = timestamp_from_compact(300);
2896        assert_eq!(last_ts, expected, "last timestamp should be the largest");
2897    }
2898
2899    #[test]
2900    fn compact_vec_empty_operations() {
2901        let vec = CompactMessageVec::new();
2902        assert!(vec.is_empty());
2903        assert_eq!(vec.len(), 0);
2904        assert!(vec.last().is_none());
2905        assert!(vec.first().is_none());
2906        assert!(vec.last_timestamp().is_none());
2907        assert!(!vec.contains_hex_id("anything"));
2908        assert!(vec.find_by_hex_id("anything").is_none());
2909    }
2910
2911    #[test]
2912    fn compact_vec_1000_message_stress() {
2913        let mut vec = CompactMessageVec::new();
2914
2915        // Insert 1000 messages
2916        for i in 0..1000u64 {
2917            let id = format!("{:0>64x}", i);
2918            let msg = make_compact_msg(&id, i * 10);
2919            assert!(vec.insert(msg), "insert {} should succeed", i);
2920        }
2921        assert_eq!(vec.len(), 1000);
2922
2923        // Verify every message can be found
2924        for i in 0..1000u64 {
2925            let id = format!("{:0>64x}", i);
2926            assert!(vec.contains_hex_id(&id), "should find message {}", i);
2927            let found = vec.find_by_hex_id(&id).unwrap();
2928            assert_eq!(found.at, i * 10, "timestamp should match for message {}", i);
2929        }
2930
2931        // Verify non-existent IDs are not found
2932        for i in 1000..1010u64 {
2933            let id = format!("{:0>64x}", i);
2934            assert!(!vec.contains_hex_id(&id), "should not find non-existent {}", i);
2935        }
2936
2937        // Messages should be in timestamp order
2938        let timestamps: Vec<u64> = vec.iter().map(|m| m.at).collect();
2939        for w in timestamps.windows(2) {
2940            assert!(w[0] <= w[1], "messages should be sorted by timestamp: {} <= {}", w[0], w[1]);
2941        }
2942    }
2943
2944    #[test]
2945    fn compact_vec_rebuild_index_after_id_change() {
2946        let mut vec = CompactMessageVec::new();
2947        let old_id = "aa00000000000000000000000000000000000000000000000000000000000000";
2948        let new_id = "ff00000000000000000000000000000000000000000000000000000000000000";
2949        vec.insert(make_compact_msg(old_id, 100));
2950
2951        // Mutate the message's ID directly (simulating an ID update like pending -> confirmed)
2952        vec.messages_mut()[0].id = encode_message_id(new_id);
2953        // Index is now stale
2954        assert!(!vec.contains_hex_id(new_id), "stale index should not find new ID");
2955
2956        // Rebuild index
2957        vec.rebuild_index();
2958        assert!(vec.contains_hex_id(new_id), "after rebuild, new ID should be found");
2959        assert!(!vec.contains_hex_id(old_id), "after rebuild, old ID should not be found");
2960    }
2961
2962    #[test]
2963    fn compact_vec_pending_id_lookup() {
2964        let mut vec = CompactMessageVec::new();
2965        let pending = "pending-9876543210";
2966        vec.insert(make_compact_msg(pending, 500));
2967
2968        assert!(vec.contains_hex_id(pending), "should find pending ID");
2969        let found = vec.find_by_hex_id(pending);
2970        assert!(found.is_some(), "should find pending message");
2971        assert_eq!(found.unwrap().id_hex(), pending, "id_hex should match pending string");
2972    }
2973
2974    #[test]
2975    fn compact_vec_out_of_order_insert() {
2976        let mut vec = CompactMessageVec::new();
2977        // Insert messages out of timestamp order
2978        vec.insert(make_compact_msg(
2979            "bb00000000000000000000000000000000000000000000000000000000000000", 300,
2980        ));
2981        vec.insert(make_compact_msg(
2982            "aa00000000000000000000000000000000000000000000000000000000000000", 100,
2983        ));
2984        vec.insert(make_compact_msg(
2985            "cc00000000000000000000000000000000000000000000000000000000000000", 200,
2986        ));
2987
2988        assert_eq!(vec.len(), 3);
2989        // Verify sorted by timestamp
2990        let timestamps: Vec<u64> = vec.iter().map(|m| m.at).collect();
2991        assert_eq!(timestamps, vec![100, 200, 300], "should be sorted by timestamp");
2992
2993        // All lookups should still work
2994        assert!(vec.contains_hex_id("aa00000000000000000000000000000000000000000000000000000000000000"));
2995        assert!(vec.contains_hex_id("bb00000000000000000000000000000000000000000000000000000000000000"));
2996        assert!(vec.contains_hex_id("cc00000000000000000000000000000000000000000000000000000000000000"));
2997    }
2998
2999    #[test]
3000    fn compact_vec_batch_prepend() {
3001        let mut vec = CompactMessageVec::new();
3002        // First insert newer messages
3003        vec.insert(make_compact_msg(
3004            "cc00000000000000000000000000000000000000000000000000000000000000", 300,
3005        ));
3006        vec.insert(make_compact_msg(
3007            "dd00000000000000000000000000000000000000000000000000000000000000", 400,
3008        ));
3009
3010        // Then batch-insert older messages (pagination scenario)
3011        let older = vec![
3012            make_compact_msg("aa00000000000000000000000000000000000000000000000000000000000000", 100),
3013            make_compact_msg("bb00000000000000000000000000000000000000000000000000000000000000", 200),
3014        ];
3015        let added = vec.insert_batch(older);
3016        assert_eq!(added, 2);
3017        assert_eq!(vec.len(), 4);
3018
3019        // Verify order
3020        let timestamps: Vec<u64> = vec.iter().map(|m| m.at).collect();
3021        assert_eq!(timestamps, vec![100, 200, 300, 400]);
3022
3023        // All lookups should work
3024        assert!(vec.contains_hex_id("aa00000000000000000000000000000000000000000000000000000000000000"));
3025        assert!(vec.contains_hex_id("dd00000000000000000000000000000000000000000000000000000000000000"));
3026    }
3027
3028    #[test]
3029    fn compact_vec_clear() {
3030        let mut vec = CompactMessageVec::new();
3031        vec.insert(make_compact_msg(
3032            "aa00000000000000000000000000000000000000000000000000000000000000", 100,
3033        ));
3034        vec.insert(make_compact_msg(
3035            "bb00000000000000000000000000000000000000000000000000000000000000", 200,
3036        ));
3037        assert_eq!(vec.len(), 2);
3038
3039        vec.clear();
3040        assert!(vec.is_empty());
3041        assert_eq!(vec.len(), 0);
3042        assert!(!vec.contains_hex_id("aa00000000000000000000000000000000000000000000000000000000000000"));
3043    }
3044
3045    // ========================================================================
3046    // CompactMessage from_message / to_message Tests
3047    // ========================================================================
3048
3049    /// Helper to create a full Message with all fields populated
3050    fn make_full_message() -> Message {
3051        Message {
3052            expiration: Some(1893456000),
3053            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3054            content: "Hello, world!".into(),
3055            replied_to: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3056            replied_to_content: Some("Original message".into()),
3057            replied_to_npub: Some("npub1replier".into()),
3058            replied_to_has_attachment: Some(true),
3059            replied_to_attachment_extension: None,
3060            preview_metadata: Some(SiteMetadata {
3061                domain: "example.com".into(),
3062                og_title: Some("Test Page".into()),
3063                og_description: Some("A test description".into()),
3064                og_image: Some("https://example.com/img.png".into()),
3065                og_url: Some("https://example.com".into()),
3066                og_type: Some("website".into()),
3067                title: Some("Test".into()),
3068                description: Some("Desc".into()),
3069                favicon: Some("https://example.com/favicon.ico".into()),
3070            }),
3071            attachments: vec![Attachment {
3072                id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3073                key: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3074                nonce: "cccccccccccccccccccccccccccccccc".into(), // 32 hex chars = 16 bytes
3075                extension: "png".into(),
3076                name: "photo.png".into(),
3077                url: "https://blossom.example.com".into(),
3078                path: "/tmp/photo.png".into(),
3079                size: 12345,
3080                img_meta: Some(ImageMetadata {
3081                    thumbhash: "abc123".into(),
3082                    width: 800,
3083                    height: 600,
3084                }),
3085                downloading: false,
3086                downloaded: true,
3087                webxdc_topic: None,
3088                group_id: None,
3089                original_hash: None,
3090                scheme_version: None,
3091                mls_filename: None,
3092            }],
3093            reactions: vec![Reaction {
3094                id: "dddd000000000000000000000000000000000000000000000000000000000000".into(),
3095                reference_id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3096                author_id: "npub1reactor".into(),
3097                emoji: "\u{1f44d}".into(), // thumbs up
3098                emoji_url: None,
3099            }],
3100            at: 1705320000000, // 2024-01-15 12:00:00 UTC ms
3101            pending: false,
3102            failed: false,
3103            mine: true,
3104            npub: Some("npub1sender".into()),
3105            wrapper_event_id: Some("eeee000000000000000000000000000000000000000000000000000000000000".into()),
3106            edited: true,
3107            edit_history: Some(vec![
3108                EditEntry { content: "Original".into(), edited_at: 1705320000000 },
3109                EditEntry { content: "Edited".into(), edited_at: 1705320060000 },
3110            ]),
3111            emoji_tags: Vec::new(),
3112            addressed_bots: vec!["npub1botrouting0000000000000000000000000000000000000000000000".into()],
3113        }
3114    }
3115
3116    #[test]
3117    fn compact_message_from_message_roundtrip_all_fields() {
3118        let msg = make_full_message();
3119        let mut interner = NpubInterner::new();
3120        let compact = CompactMessage::from_message(&msg, &mut interner);
3121        let restored = compact.to_message(&interner);
3122
3123        assert_eq!(restored.id, msg.id, "id mismatch");
3124        assert_eq!(restored.content, msg.content, "content mismatch");
3125        assert_eq!(restored.mine, msg.mine, "mine mismatch");
3126        assert_eq!(restored.pending, msg.pending, "pending mismatch");
3127        assert_eq!(restored.failed, msg.failed, "failed mismatch");
3128        assert_eq!(restored.npub, msg.npub, "npub mismatch");
3129        assert_eq!(restored.replied_to, msg.replied_to, "replied_to mismatch");
3130        assert_eq!(restored.replied_to_content, msg.replied_to_content, "replied_to_content mismatch");
3131        assert_eq!(restored.replied_to_npub, msg.replied_to_npub, "replied_to_npub mismatch");
3132        assert_eq!(restored.replied_to_has_attachment, msg.replied_to_has_attachment, "replied_to_has_attachment mismatch");
3133        assert_eq!(restored.wrapper_event_id, msg.wrapper_event_id, "wrapper_event_id mismatch");
3134        assert_eq!(restored.edited, msg.edited, "edited mismatch");
3135        assert_eq!(restored.edit_history, msg.edit_history, "edit_history mismatch");
3136        assert_eq!(restored.preview_metadata, msg.preview_metadata, "preview_metadata mismatch");
3137        // Timestamp loses sub-second precision but seconds should match
3138        assert_eq!(restored.at / 1000, msg.at / 1000, "timestamp seconds mismatch");
3139        // Attachments
3140        assert_eq!(restored.attachments.len(), 1, "should have 1 attachment");
3141        assert_eq!(restored.attachments[0].id, msg.attachments[0].id);
3142        assert_eq!(restored.attachments[0].name, msg.attachments[0].name);
3143        assert_eq!(restored.attachments[0].size, msg.attachments[0].size);
3144        // Reactions
3145        assert_eq!(restored.reactions.len(), 1, "should have 1 reaction");
3146        assert_eq!(restored.reactions[0].emoji, msg.reactions[0].emoji);
3147        // Bot routing targets round-trip through the interner.
3148        assert_eq!(restored.addressed_bots, msg.addressed_bots, "addressed_bots mismatch");
3149    }
3150
3151    #[test]
3152    fn compact_message_from_message_owned_roundtrip() {
3153        let msg = make_full_message();
3154        let msg_clone = msg.clone();
3155        let mut interner = NpubInterner::new();
3156        let compact = CompactMessage::from_message_owned(msg, &mut interner);
3157        let restored = compact.to_message(&interner);
3158
3159        assert_eq!(restored.id, msg_clone.id, "id mismatch");
3160        assert_eq!(restored.content, msg_clone.content, "content mismatch");
3161        assert_eq!(restored.mine, msg_clone.mine, "mine mismatch");
3162        assert_eq!(restored.npub, msg_clone.npub, "npub mismatch");
3163        assert_eq!(restored.edit_history, msg_clone.edit_history, "edit_history mismatch");
3164    }
3165
3166    #[test]
3167    fn compact_message_pending_flag() {
3168        let msg = Message {
3169            id: "pending-1234567890".into(),
3170            pending: true,
3171            ..Message::default()
3172        };
3173        let mut interner = NpubInterner::new();
3174        let compact = CompactMessage::from_message(&msg, &mut interner);
3175        assert!(compact.is_pending(), "pending flag should be set");
3176        let restored = compact.to_message(&interner);
3177        assert!(restored.pending, "pending should roundtrip");
3178        assert_eq!(restored.id, "pending-1234567890", "pending ID should roundtrip");
3179    }
3180
3181    #[test]
3182    fn compact_message_failed_flag() {
3183        let msg = Message {
3184            id: "pending-999".into(),
3185            failed: true,
3186            pending: true,
3187            ..Message::default()
3188        };
3189        let mut interner = NpubInterner::new();
3190        let compact = CompactMessage::from_message(&msg, &mut interner);
3191        assert!(compact.is_failed(), "failed flag should be set");
3192        assert!(compact.is_pending(), "pending flag should also be set");
3193        let restored = compact.to_message(&interner);
3194        assert!(restored.failed);
3195        assert!(restored.pending);
3196    }
3197
3198    #[test]
3199    fn compact_message_with_attachments_roundtrip() {
3200        let msg = Message {
3201            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3202            attachments: vec![
3203                Attachment {
3204                    id: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3205                    extension: "jpg".into(),
3206                    name: "sunset.jpg".into(),
3207                    size: 5000,
3208                    downloaded: true,
3209                    ..Attachment::default()
3210                },
3211                Attachment {
3212                    id: "2222222222222222222222222222222222222222222222222222222222222222".into(),
3213                    extension: "mp4".into(),
3214                    name: "video.mp4".into(),
3215                    size: 50000,
3216                    downloaded: false,
3217                    downloading: true,
3218                    ..Attachment::default()
3219                },
3220            ],
3221            ..Message::default()
3222        };
3223        let mut interner = NpubInterner::new();
3224        let compact = CompactMessage::from_message(&msg, &mut interner);
3225        assert_eq!(compact.attachments.len(), 2);
3226
3227        let restored = compact.to_message(&interner);
3228        assert_eq!(restored.attachments.len(), 2);
3229        assert_eq!(restored.attachments[0].name, "sunset.jpg");
3230        assert_eq!(restored.attachments[0].extension, "jpg");
3231        assert!(restored.attachments[0].downloaded);
3232        assert_eq!(restored.attachments[1].name, "video.mp4");
3233        assert!(restored.attachments[1].downloading);
3234        assert!(!restored.attachments[1].downloaded);
3235    }
3236
3237    #[test]
3238    fn compact_message_with_reactions_roundtrip() {
3239        let msg = Message {
3240            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3241            reactions: vec![
3242                Reaction {
3243                    id: "aaa0000000000000000000000000000000000000000000000000000000000000".into(),
3244                    reference_id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3245                    author_id: "npub1alice".into(),
3246                    emoji: "\u{2764}".into(), // heart
3247                    emoji_url: None,
3248                },
3249                Reaction {
3250                    id: "bbb0000000000000000000000000000000000000000000000000000000000000".into(),
3251                    reference_id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3252                    author_id: "npub1bob".into(),
3253                    emoji: "\u{1f525}".into(), // fire
3254                    emoji_url: None,
3255                },
3256            ],
3257            ..Message::default()
3258        };
3259        let mut interner = NpubInterner::new();
3260        let compact = CompactMessage::from_message(&msg, &mut interner);
3261        let restored = compact.to_message(&interner);
3262
3263        assert_eq!(restored.reactions.len(), 2);
3264        assert_eq!(restored.reactions[0].emoji, "\u{2764}");
3265        assert_eq!(restored.reactions[0].author_id, "npub1alice");
3266        assert_eq!(restored.reactions[1].emoji, "\u{1f525}");
3267        assert_eq!(restored.reactions[1].author_id, "npub1bob");
3268    }
3269
3270    #[test]
3271    fn compact_message_with_edit_history_roundtrip() {
3272        let msg = Message {
3273            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3274            content: "Final version".into(),
3275            edited: true,
3276            edit_history: Some(vec![
3277                EditEntry { content: "First draft".into(), edited_at: 1000 },
3278                EditEntry { content: "Second draft".into(), edited_at: 2000 },
3279                EditEntry { content: "Final version".into(), edited_at: 3000 },
3280            ]),
3281            ..Message::default()
3282        };
3283        let mut interner = NpubInterner::new();
3284        let compact = CompactMessage::from_message(&msg, &mut interner);
3285        assert!(compact.is_edited());
3286
3287        let restored = compact.to_message(&interner);
3288        assert!(restored.edited);
3289        let history = restored.edit_history.unwrap();
3290        assert_eq!(history.len(), 3);
3291        assert_eq!(history[0].content, "First draft");
3292        assert_eq!(history[2].content, "Final version");
3293    }
3294
3295    #[test]
3296    fn compact_message_with_preview_metadata_roundtrip() {
3297        let meta = SiteMetadata {
3298            domain: "example.com".into(),
3299            og_title: Some("Title".into()),
3300            og_description: Some("Desc".into()),
3301            og_image: Some("https://example.com/img.png".into()),
3302            og_url: None,
3303            og_type: None,
3304            title: None,
3305            description: None,
3306            favicon: None,
3307        };
3308        let msg = Message {
3309            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3310            preview_metadata: Some(meta.clone()),
3311            ..Message::default()
3312        };
3313        let mut interner = NpubInterner::new();
3314        let compact = CompactMessage::from_message(&msg, &mut interner);
3315        let restored = compact.to_message(&interner);
3316
3317        let restored_meta = restored.preview_metadata.unwrap();
3318        assert_eq!(restored_meta.domain, "example.com");
3319        assert_eq!(restored_meta.og_title, Some("Title".into()));
3320        assert_eq!(restored_meta.og_image, Some("https://example.com/img.png".into()));
3321        assert_eq!(restored_meta.og_url, None);
3322    }
3323
3324    #[test]
3325    fn compact_message_with_replied_to_roundtrip() {
3326        let msg = Message {
3327            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3328            replied_to: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3329            replied_to_content: Some("Original text".into()),
3330            replied_to_npub: Some("npub1original".into()),
3331            replied_to_has_attachment: Some(false),
3332            ..Message::default()
3333        };
3334        let mut interner = NpubInterner::new();
3335        let compact = CompactMessage::from_message(&msg, &mut interner);
3336        assert!(compact.has_reply());
3337
3338        let restored = compact.to_message(&interner);
3339        assert_eq!(restored.replied_to, "1111111111111111111111111111111111111111111111111111111111111111");
3340        assert_eq!(restored.replied_to_content, Some("Original text".into()));
3341        assert_eq!(restored.replied_to_npub, Some("npub1original".into()));
3342        assert_eq!(restored.replied_to_has_attachment, Some(false));
3343    }
3344
3345    #[test]
3346    fn compact_message_empty_roundtrip() {
3347        let msg = Message::default();
3348        let mut interner = NpubInterner::new();
3349        let compact = CompactMessage::from_message(&msg, &mut interner);
3350        let restored = compact.to_message(&interner);
3351
3352        assert_eq!(restored.id, "0000000000000000000000000000000000000000000000000000000000000000",
3353            "empty ID should decode as all zeros hex");
3354        assert_eq!(restored.content, "");
3355        assert!(!restored.mine);
3356        assert!(!restored.pending);
3357        assert!(!restored.failed);
3358        assert!(!restored.edited);
3359        assert_eq!(restored.npub, None);
3360        assert!(restored.replied_to.is_empty() || restored.replied_to == "0000000000000000000000000000000000000000000000000000000000000000");
3361        assert_eq!(restored.replied_to_content, None);
3362        assert_eq!(restored.replied_to_npub, None);
3363        assert_eq!(restored.replied_to_has_attachment, None);
3364        assert_eq!(restored.wrapper_event_id, None);
3365        assert!(restored.attachments.is_empty());
3366        assert!(restored.reactions.is_empty());
3367        assert_eq!(restored.edit_history, None);
3368        assert_eq!(restored.preview_metadata, None);
3369    }
3370
3371    // ========================================================================
3372    // CompactAttachment Tests
3373    // ========================================================================
3374
3375    #[test]
3376    fn compact_attachment_from_attachment_roundtrip() {
3377        let att = Attachment {
3378            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3379            key: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3380            nonce: "aabbccddaabbccddaabbccddaabbccdd".into(), // 32 hex = 16-byte DM nonce
3381            extension: "zip".into(),
3382            name: "archive.zip".into(),
3383            url: "https://blossom.test.com".into(),
3384            path: "/downloads/archive.zip".into(),
3385            size: 99999,
3386            img_meta: None,
3387            downloading: false,
3388            downloaded: true,
3389            webxdc_topic: None,
3390            group_id: None,
3391            original_hash: None,
3392            scheme_version: None,
3393            mls_filename: None,
3394        };
3395
3396        let compact = CompactAttachment::from_attachment(&att);
3397        let restored = compact.to_attachment();
3398
3399        assert_eq!(restored.id, att.id, "id mismatch");
3400        assert_eq!(restored.key, att.key, "key mismatch");
3401        assert_eq!(restored.nonce, att.nonce, "nonce mismatch");
3402        assert_eq!(restored.extension, att.extension, "extension mismatch");
3403        assert_eq!(restored.name, att.name, "name mismatch");
3404        assert_eq!(restored.url, att.url, "url mismatch");
3405        assert_eq!(restored.path, att.path, "path mismatch");
3406        assert_eq!(restored.size, att.size, "size mismatch");
3407        assert_eq!(restored.downloading, att.downloading, "downloading mismatch");
3408        assert_eq!(restored.downloaded, att.downloaded, "downloaded mismatch");
3409    }
3410
3411    #[test]
3412    fn compact_attachment_from_attachment_owned_roundtrip() {
3413        let att = Attachment {
3414            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3415            key: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3416            nonce: "aabbccddaabbccddaabbccddaabbccdd".into(),
3417            extension: "pdf".into(),
3418            name: "document.pdf".into(),
3419            url: "https://server.com".into(),
3420            path: "".into(),
3421            size: 1024,
3422            img_meta: None,
3423            downloading: false,
3424            downloaded: false,
3425            webxdc_topic: None,
3426            group_id: None,
3427            original_hash: None,
3428            scheme_version: None,
3429            mls_filename: None,
3430        };
3431        let att_clone = att.clone();
3432
3433        let compact = CompactAttachment::from_attachment_owned(att);
3434        let restored = compact.to_attachment();
3435
3436        assert_eq!(restored.id, att_clone.id);
3437        assert_eq!(restored.name, att_clone.name);
3438        assert_eq!(restored.size, att_clone.size);
3439    }
3440
3441    #[test]
3442    fn compact_attachment_key_nonce_zeros() {
3443        let att = Attachment {
3444            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3445            key: "".into(), // empty key = legacy derived
3446            nonce: "".into(), // empty nonce
3447            ..Attachment::default()
3448        };
3449        let compact = CompactAttachment::from_attachment(&att);
3450        assert_eq!(compact.key, [0u8; 32], "empty key should be all zeros");
3451        assert_eq!(compact.nonce, [0u8; 16], "empty nonce should be all zeros");
3452
3453        let restored = compact.to_attachment();
3454        assert_eq!(restored.key, "", "zero key should restore as empty string");
3455        assert_eq!(restored.nonce, "", "zero nonce should restore as empty string");
3456    }
3457
3458    #[test]
3459    fn compact_attachment_short_nonce_mls_12byte() {
3460        // Legacy short nonce is 12 bytes = 24 hex chars
3461        let att = Attachment {
3462            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3463            nonce: "aabbccddaabbccddaabbccdd".into(), // 24 hex chars = 12 bytes
3464            ..Attachment::default()
3465        };
3466        let compact = CompactAttachment::from_attachment(&att);
3467        assert!(compact.flags.is_short_nonce(), "12-byte nonce should set short_nonce flag");
3468
3469        let restored = compact.to_attachment();
3470        assert_eq!(restored.nonce, "aabbccddaabbccddaabbccdd", "short nonce should roundtrip");
3471    }
3472
3473    #[test]
3474    fn compact_attachment_long_nonce_dm_16byte() {
3475        // DM nonce is 16 bytes = 32 hex chars
3476        let att = Attachment {
3477            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3478            nonce: "aabbccddaabbccddaabbccddaabbccdd".into(), // 32 hex chars = 16 bytes
3479            ..Attachment::default()
3480        };
3481        let compact = CompactAttachment::from_attachment(&att);
3482        assert!(!compact.flags.is_short_nonce(), "16-byte nonce should NOT set short_nonce flag");
3483
3484        let restored = compact.to_attachment();
3485        assert_eq!(restored.nonce, "aabbccddaabbccddaabbccddaabbccdd", "long nonce should roundtrip");
3486    }
3487
3488    #[test]
3489    fn compact_attachment_id_eq_comparison() {
3490        let att = Attachment {
3491            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3492            ..Attachment::default()
3493        };
3494        let compact = CompactAttachment::from_attachment(&att);
3495
3496        assert!(compact.id_eq("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
3497            "id_eq should match same hex");
3498        assert!(!compact.id_eq("1111111111111111111111111111111111111111111111111111111111111111"),
3499            "id_eq should not match different hex");
3500    }
3501
3502    #[test]
3503    fn compact_attachment_all_optional_fields_none() {
3504        let att = Attachment::default();
3505        let compact = CompactAttachment::from_attachment(&att);
3506
3507        assert!(compact.img_meta.is_none());
3508        assert!(compact.group_id.is_none());
3509        assert!(compact.original_hash.is_none());
3510        assert!(compact.webxdc_topic.is_none());
3511        assert!(compact.mls_filename.is_none());
3512        assert!(compact.scheme_version.is_none());
3513
3514        let restored = compact.to_attachment();
3515        assert!(restored.img_meta.is_none());
3516        assert!(restored.group_id.is_none());
3517        assert!(restored.original_hash.is_none());
3518        assert!(restored.webxdc_topic.is_none());
3519        assert!(restored.mls_filename.is_none());
3520        assert!(restored.scheme_version.is_none());
3521    }
3522
3523    #[test]
3524    fn compact_attachment_all_optional_fields_some() {
3525        let att = Attachment {
3526            id: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".into(),
3527            key: "1111111111111111111111111111111111111111111111111111111111111111".into(),
3528            nonce: "aabbccddaabbccddaabbccddaabbccdd".into(),
3529            extension: "xdc".into(),
3530            name: "app.xdc".into(),
3531            url: "https://server.com/file".into(),
3532            path: "/local/app.xdc".into(),
3533            size: 50000,
3534            img_meta: Some(ImageMetadata {
3535                thumbhash: "hash123".into(),
3536                width: 1920,
3537                height: 1080,
3538            }),
3539            downloading: false,
3540            downloaded: true,
3541            webxdc_topic: Some("game-state".into()),
3542            group_id: Some("cccc000000000000000000000000000000000000000000000000000000000000".into()),
3543            original_hash: Some("dddd000000000000000000000000000000000000000000000000000000000000".into()),
3544            scheme_version: Some("mip04-v1".into()),
3545            mls_filename: Some("encrypted.bin".into()),
3546        };
3547
3548        let compact = CompactAttachment::from_attachment(&att);
3549
3550        assert!(compact.img_meta.is_some());
3551        assert!(compact.group_id.is_some());
3552        assert!(compact.original_hash.is_some());
3553        assert!(compact.webxdc_topic.is_some());
3554        assert!(compact.mls_filename.is_some());
3555        assert!(compact.scheme_version.is_some());
3556
3557        let restored = compact.to_attachment();
3558        let meta = restored.img_meta.unwrap();
3559        assert_eq!(meta.thumbhash, "hash123");
3560        assert_eq!(meta.width, 1920);
3561        assert_eq!(meta.height, 1080);
3562        assert_eq!(restored.webxdc_topic, Some("game-state".into()));
3563        assert_eq!(restored.group_id.unwrap(), att.group_id.unwrap());
3564        assert_eq!(restored.original_hash.unwrap(), att.original_hash.unwrap());
3565        assert_eq!(restored.scheme_version, Some("mip04-v1".into()));
3566        assert_eq!(restored.mls_filename, Some("encrypted.bin".into()));
3567    }
3568
3569    // ========================================================================
3570    // CompactReaction Tests
3571    // ========================================================================
3572
3573    #[test]
3574    fn compact_reaction_from_reaction_roundtrip() {
3575        let reaction = Reaction {
3576            id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3577            reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3578            author_id: "npub1alice".into(),
3579            emoji: "+".into(),
3580            emoji_url: None,
3581        };
3582        let mut interner = NpubInterner::new();
3583        let compact = CompactReaction::from_reaction(&reaction, &mut interner);
3584        let restored = compact.to_reaction(&interner);
3585
3586        assert_eq!(restored.id, reaction.id, "id mismatch");
3587        assert_eq!(restored.reference_id, reaction.reference_id, "reference_id mismatch");
3588        assert_eq!(restored.author_id, reaction.author_id, "author_id mismatch");
3589        assert_eq!(restored.emoji, reaction.emoji, "emoji mismatch");
3590    }
3591
3592    #[test]
3593    fn compact_reaction_author_resolved_via_interner() {
3594        let mut interner = NpubInterner::new();
3595        let alice_handle = interner.intern("npub1alice");
3596
3597        let reaction = Reaction {
3598            id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3599            reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3600            author_id: "npub1alice".into(),
3601            emoji: "+".into(),
3602            emoji_url: None,
3603        };
3604        let compact = CompactReaction::from_reaction(&reaction, &mut interner);
3605        assert_eq!(compact.author_idx, alice_handle, "should reuse existing interner handle");
3606
3607        let resolved = interner.resolve(compact.author_idx).unwrap();
3608        assert_eq!(resolved, "npub1alice");
3609    }
3610
3611    #[test]
3612    fn compact_reaction_unicode_emoji() {
3613        let reaction = Reaction {
3614            id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3615            reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3616            author_id: "npub1test".into(),
3617            emoji: "\u{1f431}\u{200d}\u{1f4bb}".into(), // cat with laptop (ZWJ sequence)
3618            emoji_url: None,
3619        };
3620        let mut interner = NpubInterner::new();
3621        let compact = CompactReaction::from_reaction(&reaction, &mut interner);
3622        let restored = compact.to_reaction(&interner);
3623        assert_eq!(restored.emoji, "\u{1f431}\u{200d}\u{1f4bb}", "complex unicode emoji should roundtrip");
3624    }
3625
3626    #[test]
3627    fn compact_reaction_custom_emoji() {
3628        let reaction = Reaction {
3629            id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3630            reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3631            author_id: "npub1test".into(),
3632            emoji: ":cat_heart_eyes:".into(),
3633            emoji_url: None,
3634        };
3635        let mut interner = NpubInterner::new();
3636        let compact = CompactReaction::from_reaction(&reaction, &mut interner);
3637        let restored = compact.to_reaction(&interner);
3638        assert_eq!(restored.emoji, ":cat_heart_eyes:", "custom emoji shortcode should roundtrip");
3639    }
3640
3641    #[test]
3642    fn compact_reaction_owned_conversion() {
3643        let reaction = Reaction {
3644            id: "aaaa000000000000000000000000000000000000000000000000000000000000".into(),
3645            reference_id: "bbbb000000000000000000000000000000000000000000000000000000000000".into(),
3646            author_id: "npub1bob".into(),
3647            emoji: "\u{1f44d}".into(), // thumbs up
3648            emoji_url: None,
3649        };
3650        let reaction_clone = reaction.clone();
3651        let mut interner = NpubInterner::new();
3652        let compact = CompactReaction::from_reaction_owned(reaction, &mut interner);
3653        let restored = compact.to_reaction(&interner);
3654
3655        assert_eq!(restored.id, reaction_clone.id);
3656        assert_eq!(restored.author_id, reaction_clone.author_id);
3657        assert_eq!(restored.emoji, reaction_clone.emoji);
3658    }
3659
3660    // ========================================================================
3661    // TinyVec Tests
3662    // ========================================================================
3663
3664    #[test]
3665    fn tinyvec_empty() {
3666        let tv: TinyVec<u32> = TinyVec::new();
3667        assert!(tv.is_empty());
3668        assert_eq!(tv.len(), 0);
3669        assert_eq!(tv.as_slice(), &[] as &[u32]);
3670        assert_eq!(tv.first(), None);
3671        assert_eq!(tv.last(), None);
3672    }
3673
3674    #[test]
3675    fn tinyvec_from_vec_and_back() {
3676        let original = vec![1u32, 2, 3, 4, 5];
3677        let tv = TinyVec::from_vec(original.clone());
3678        assert_eq!(tv.len(), 5);
3679        assert_eq!(tv.to_vec(), original);
3680    }
3681
3682    #[test]
3683    fn tinyvec_indexing() {
3684        let tv = TinyVec::from_vec(vec![10u32, 20, 30]);
3685        assert_eq!(tv[0], 10);
3686        assert_eq!(tv[1], 20);
3687        assert_eq!(tv[2], 30);
3688        assert_eq!(tv.get(0), Some(&10));
3689        assert_eq!(tv.get(3), None);
3690    }
3691
3692    #[test]
3693    fn tinyvec_push() {
3694        let mut tv = TinyVec::from_vec(vec![1u32, 2]);
3695        tv.push(3);
3696        assert_eq!(tv.len(), 3);
3697        assert_eq!(tv.to_vec(), vec![1, 2, 3]);
3698    }
3699
3700    #[test]
3701    fn tinyvec_clone() {
3702        let tv = TinyVec::from_vec(vec!["hello".to_string(), "world".to_string()]);
3703        let cloned = tv.clone();
3704        assert_eq!(cloned.len(), 2);
3705        assert_eq!(cloned[0], "hello");
3706        assert_eq!(cloned[1], "world");
3707    }
3708
3709    #[test]
3710    fn tinyvec_retain() {
3711        let mut tv = TinyVec::from_vec(vec![1u32, 2, 3, 4, 5]);
3712        tv.retain(|&x| x % 2 == 0);
3713        assert_eq!(tv.to_vec(), vec![2, 4]);
3714    }
3715
3716    #[test]
3717    fn tinyvec_empty_from_empty_vec() {
3718        let tv = TinyVec::<u32>::from_vec(vec![]);
3719        assert!(tv.is_empty());
3720        assert_eq!(tv.len(), 0);
3721    }
3722
3723    #[test]
3724    fn tinyvec_iter() {
3725        let tv = TinyVec::from_vec(vec![10u32, 20, 30]);
3726        let sum: u32 = tv.iter().sum();
3727        assert_eq!(sum, 60);
3728    }
3729
3730    #[test]
3731    fn tinyvec_any() {
3732        let tv = TinyVec::from_vec(vec![1u32, 2, 3]);
3733        assert!(tv.any(|&x| x == 2));
3734        assert!(!tv.any(|&x| x == 99));
3735    }
3736
3737    // ========================================================================
3738    // hex_to_bytes_16 / bytes_to_hex_string Tests
3739    // ========================================================================
3740
3741    #[test]
3742    fn hex_to_bytes_16_full_32_chars() {
3743        let hex = "aabbccddaabbccddaabbccddaabbccdd";
3744        let bytes = hex_to_bytes_16(hex);
3745        assert_eq!(bytes[0], 0xaa);
3746        assert_eq!(bytes[1], 0xbb);
3747        assert_eq!(bytes[15], 0xdd);
3748    }
3749
3750    #[test]
3751    fn hex_to_bytes_16_short_input_padded() {
3752        // Short input gets right-padded with '0' before decode
3753        let hex = "aabb";
3754        let bytes = hex_to_bytes_16(hex);
3755        assert_eq!(bytes[0], 0xaa);
3756        assert_eq!(bytes[1], 0xbb);
3757        // Remaining bytes should be 0 (from padding)
3758        for i in 2..16 {
3759            assert_eq!(bytes[i], 0, "byte {} should be 0 from padding", i);
3760        }
3761    }
3762
3763    #[test]
3764    fn bytes_to_hex_string_roundtrip() {
3765        let bytes: Vec<u8> = vec![0xaa, 0xbb, 0xcc, 0xdd];
3766        let hex = bytes_to_hex_string(&bytes);
3767        assert_eq!(hex, "aabbccdd");
3768    }
3769}