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