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