Skip to main content

vector_core/
compact.rs

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