Skip to main content

vector_core/db/
events.rs

1//! Event storage — save_event for the flat event architecture.
2
3use crate::stored_event::{StoredEvent, event_kind};
4use rusqlite::OptionalExtension;
5use crate::crypto::maybe_encrypt;
6use crate::types::{Message, Attachment, Reaction};
7
8/// Save a StoredEvent to the events table.
9///
10/// Primary storage function for the flat event architecture.
11/// Conditionally encrypts message/edit content based on user setting.
12/// Uses INSERT OR REPLACE with COALESCE to preserve existing wrapper_event_id.
13/// Conditionally encrypt an event's content per kind (messages/edits are encrypted at rest). Async,
14/// so callers run it BEFORE opening a sync transaction.
15async fn encrypt_event_content(event: &StoredEvent) -> String {
16    if event.kind == event_kind::CHAT_MESSAGE
17        || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
18        || event.kind == event_kind::MESSAGE_EDIT
19    {
20        maybe_encrypt(event.content.clone()).await
21    } else {
22        event.content.clone()
23    }
24}
25
26/// Upsert the event row onto the given connection or transaction (so it can commit atomically with
27/// its attachment rows). `content` must already be encrypted (see `encrypt_event_content`).
28///
29/// UPSERT (not INSERT OR REPLACE) so a re-save (reaction/edit) UPDATES in place and PRESERVES the
30/// rowid. get_messages_around's (created_at, received_at, rowid) cursor needs a stable final
31/// tiebreak to page through same-timestamp bursts; INSERT OR REPLACE churns the rowid and drops rows.
32fn insert_event_row(conn: &rusqlite::Connection, event: &StoredEvent, content: &str, tags_json: &str) -> Result<(), String> {
33    // prepare_cached: this is the hottest write statement in the app — the cache lives on the
34    // connection, so bulk-sync batches and every realtime save skip the SQL re-parse.
35    let mut stmt = conn.prepare_cached(
36        r#"
37        INSERT INTO events (
38            id, kind, chat_id, user_id, content, tags, reference_id,
39            created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata
40        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
41        ON CONFLICT(id) DO UPDATE SET
42            kind = excluded.kind, chat_id = excluded.chat_id, user_id = excluded.user_id,
43            content = excluded.content, tags = excluded.tags, reference_id = excluded.reference_id,
44            created_at = excluded.created_at, received_at = excluded.received_at,
45            mine = excluded.mine, pending = excluded.pending, failed = excluded.failed,
46            wrapper_event_id = COALESCE(excluded.wrapper_event_id, events.wrapper_event_id),
47            npub = excluded.npub, preview_metadata = excluded.preview_metadata
48        "#,
49    ).map_err(|e| format!("prepare save event: {}", e))?;
50    stmt.execute(
51        rusqlite::params![
52            event.id, event.kind as i32, event.chat_id, event.user_id, content, tags_json,
53            event.reference_id, event.created_at as i64, event.received_at as i64,
54            event.mine as i32, event.pending as i32, event.failed as i32,
55            event.wrapper_event_id, event.npub, event.preview_metadata,
56        ],
57    ).map_err(|e| format!("Failed to save event: {}", e))?;
58    Ok(())
59}
60
61pub async fn save_event(event: &StoredEvent) -> Result<(), String> {
62    let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
63    let content = encrypt_event_content(event).await;
64    let conn = super::get_write_connection_guard_static()?;
65    insert_event_row(&conn, event, &content, &tags_json)
66}
67
68/// Extract persisted `["bot", npub]` routing tags (the write side lives in
69/// `message_to_stored_event`).
70fn extract_bot_tags(tags: &[Vec<String>]) -> Vec<String> {
71    tags.iter()
72        .filter(|t| t.len() >= 2 && t[0] == "bot")
73        .map(|t| t[1].clone())
74        .collect()
75}
76
77/// Parse the NIP-40 `["expiration", <unix secs>]` tag, if present. Drives the
78/// self-destruct countdown + purge for messages rehydrated from the DB.
79fn extract_expiration_tag(tags: &[Vec<String>]) -> Option<u64> {
80    tags.iter()
81        .find(|t| t.len() >= 2 && t[0] == "expiration")
82        .and_then(|t| t[1].parse::<u64>().ok())
83}
84
85/// Check if an event exists in the database.
86pub fn event_exists(event_id: &str) -> Result<bool, String> {
87    let conn = super::get_db_connection_guard_static()?;
88    event_exists_on(&conn, event_id)
89}
90
91/// `event_exists` against a caller-held connection or transaction — an in-transaction check
92/// sees the batch's own uncommitted rows, which the pooled read connection cannot.
93fn event_exists_on(conn: &rusqlite::Connection, event_id: &str) -> Result<bool, String> {
94    let mut stmt = conn.prepare_cached("SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)")
95        .map_err(|e| format!("prepare event existence: {}", e))?;
96    stmt.query_row(rusqlite::params![event_id], |row| row.get(0))
97        .map_err(|e| format!("Failed to check event existence: {}", e))
98}
99
100/// Build the kind=7 StoredEvent for a reaction (shared by the single-save and batch paths).
101fn reaction_to_stored_event(
102    reaction: &Reaction,
103    chat_id: i64,
104    user_id: Option<i64>,
105    mine: bool,
106    wrapper_event_id: Option<String>,
107) -> StoredEvent {
108    // Persist the NIP-30 emoji tag alongside the `e` reference so the
109    // image URL survives reload — pure `arrEmojiPacks` lookup would
110    // fail when the user hasn't yet opened the picker or unsubscribed.
111    let mut tags: Vec<Vec<String>> = vec![
112        vec!["e".to_string(), reaction.reference_id.clone()],
113    ];
114    if let Some(url) = &reaction.emoji_url {
115        if reaction.emoji.starts_with(':') && reaction.emoji.ends_with(':') && reaction.emoji.len() >= 3 {
116            let shortcode = &reaction.emoji[1..reaction.emoji.len() - 1];
117            if !shortcode.is_empty() && !url.is_empty() {
118                tags.push(vec!["emoji".to_string(), shortcode.to_string(), url.clone()]);
119            }
120        }
121    }
122    StoredEvent {
123        id: reaction.id.clone(),
124        kind: event_kind::REACTION,
125        chat_id,
126        user_id,
127        content: reaction.emoji.clone(),
128        tags,
129        reference_id: Some(reaction.reference_id.clone()),
130        created_at: std::time::SystemTime::now()
131            .duration_since(std::time::UNIX_EPOCH)
132            .map(|d| d.as_secs()).unwrap_or(0),
133        received_at: std::time::SystemTime::now()
134            .duration_since(std::time::UNIX_EPOCH)
135            .map(|d| d.as_millis() as u64).unwrap_or(0),
136        mine,
137        pending: false,
138        failed: false,
139        wrapper_event_id,
140        npub: Some(reaction.author_id.clone()),
141        preview_metadata: None,
142    }
143}
144
145/// Save a reaction as a kind=7 event referencing the message.
146pub async fn save_reaction_event(
147    reaction: &Reaction,
148    chat_id: i64,
149    user_id: Option<i64>,
150    mine: bool,
151    wrapper_event_id: Option<String>,
152) -> Result<(), String> {
153    let event = reaction_to_stored_event(reaction, chat_id, user_id, mine, wrapper_event_id);
154    save_event(&event).await
155}
156
157// ============================================================================
158// save_message — Message → StoredEvent → DB
159// ============================================================================
160
161/// Save a single message to the database.
162///
163/// Converts Message to StoredEvent and saves via the flat event architecture.
164/// Also saves reactions as separate kind=7 events.
165pub async fn save_message(chat_id: &str, message: &Message) -> Result<(), String> {
166    let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
167
168    let user_int_id = if let Some(ref npub_str) = message.npub {
169        super::id_cache::get_or_create_user_id(npub_str)?
170    } else {
171        None
172    };
173
174    let event = message_to_stored_event(message, chat_int_id, user_int_id);
175
176    // Commit the event row and its attachment rows (the dedicated table is the sole source of truth;
177    // pre-migration events keep their legacy tag as an un-read fallback) in ONE transaction, so a
178    // file message can never persist without its attachments — new events have no tag to fall back
179    // on. Encrypt first: encryption is async and can't run inside the sync transaction.
180    let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
181    let content = encrypt_event_content(&event).await;
182    {
183        let conn = super::get_write_connection_guard_static()?;
184        let tx = conn.unchecked_transaction().map_err(|e| format!("save_message tx: {e}"))?;
185        insert_event_row(&tx, &event, &content, &tags_json)?;
186        super::attachments::insert_attachment_rows(&tx, &message.id, &message.attachments)?;
187        tx.commit().map_err(|e| format!("save_message commit: {e}"))?;
188    }
189
190    // Save reactions as separate kind=7 events
191    for reaction in &message.reactions {
192        if !event_exists(&reaction.id)? {
193            let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
194            let is_mine = super::get_current_account()
195                .map(|npub| reaction.author_id == npub)
196                .unwrap_or(false);
197            save_reaction_event(reaction, chat_int_id, user_id, is_mine, None).await?;
198        }
199    }
200
201    Ok(())
202}
203
204/// One fully-prepared batch row: the message, its encrypted event row, its prepared
205/// reaction rows, and (DM stream only) the gift-wrap ledger entry that must commit in the
206/// SAME transaction — everything phase 2 needs with zero async work and zero id_cache calls.
207struct BatchRow<'a> {
208    message: &'a Message,
209    event: StoredEvent,
210    content: String,
211    tags_json: String,
212    reactions: Vec<(StoredEvent, String)>,
213    /// `(wrapper_id_bytes, wrapper_created_at)` — written to `processed_wrappers` only
214    /// AFTER this row lands. The ledger is the negentropy fingerprint set: a wrapper
215    /// ledgered before its row commits marks the message "have" forever if the row is lost.
216    wrapper: Option<([u8; 32], u64)>,
217}
218
219/// Phase 1 of a batched save (async): resolve ids, build StoredEvents, encrypt contents.
220/// ALL id_cache lookups happen here — get_or_create can write a fresh chat/user row, so it
221/// must never run while phase 2 holds the write-connection guard.
222async fn prepare_batch_rows<'a>(
223    chat_id: &str,
224    messages: &[(&'a Message, Option<([u8; 32], u64)>)],
225    rows: &mut Vec<BatchRow<'a>>,
226) -> Result<(), String> {
227    let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
228    let my_npub = super::get_current_account();
229    for (message, wrapper) in messages {
230        let user_int_id = match &message.npub {
231            Some(npub_str) => super::id_cache::get_or_create_user_id(npub_str)?,
232            None => None,
233        };
234        let event = message_to_stored_event(message, chat_int_id, user_int_id);
235        let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
236        let content = encrypt_event_content(&event).await;
237        let mut reactions: Vec<(StoredEvent, String)> = Vec::with_capacity(message.reactions.len());
238        for reaction in &message.reactions {
239            let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
240            let is_mine = my_npub.as_deref().map(|n| reaction.author_id == n).unwrap_or(false);
241            let rev = reaction_to_stored_event(reaction, chat_int_id, user_id, is_mine, None);
242            let rtags = serde_json::to_string(&rev.tags).unwrap_or_else(|_| "[]".to_string());
243            reactions.push((rev, rtags));
244        }
245        rows.push(BatchRow { message, event, content, tags_json, reactions, wrapper: *wrapper });
246    }
247    Ok(())
248}
249
250/// Phase 2 of a batched save (sync): ONE transaction for every event + attachment + reaction
251/// + wrapper-ledger row. Insert order follows slice order, preserving the rowid tiebreak
252/// that same-timestamp pagination depends on. A poison message SKIPS (logged) rather than
253/// aborting the batch — one bad row must not lose the other 49. Returns how many messages
254/// were written.
255fn write_batch_rows(rows: &[BatchRow<'_>]) -> Result<usize, String> {
256    let conn = super::get_write_connection_guard_static()?;
257    let tx = conn.unchecked_transaction().map_err(|e| format!("batch tx: {e}"))?;
258    let mut saved = 0usize;
259    for row in rows {
260        // Per-row savepoint = save_message's per-message atomicity inside the batch: a
261        // failed event/attachment write unwinds THIS row completely — including partial
262        // attachment upserts onto a pre-existing row, which a bare DELETE would destroy
263        // (a re-saved old file message must keep its download record on a transient error).
264        tx.execute_batch("SAVEPOINT batch_row").map_err(|e| format!("batch savepoint: {e}"))?;
265        let row_written = insert_event_row(&tx, &row.event, &row.content, &row.tags_json)
266            .and_then(|_| super::attachments::insert_attachment_rows(&tx, &row.message.id, &row.message.attachments));
267        if let Err(e) = row_written {
268            crate::log_warn!("[DB] batch skip {}: {}", &row.message.id[..8.min(row.message.id.len())], e);
269            let _ = tx.execute_batch("ROLLBACK TO batch_row; RELEASE batch_row");
270            continue;
271        }
272        saved += 1;
273        for (rev, rtags) in &row.reactions {
274            // Exists-check ON the tx so a reaction already inserted earlier in this batch dedups
275            // (a fresh reaction row must not clobber one that arrived with a wrapper id).
276            if event_exists_on(&tx, &rev.id).unwrap_or(true) {
277                continue;
278            }
279            if let Err(e) = insert_event_row(&tx, rev, &rev.content, rtags) {
280                crate::log_warn!("[DB] batch reaction {}: {}", &rev.id[..8.min(rev.id.len())], e);
281            }
282        }
283        // Ledger the gift-wrap only now that its row is in the tx — commit lands both or neither.
284        if let Some((wrapper_id, wrapper_created_at)) = &row.wrapper {
285            let mut stmt = tx.prepare_cached(
286                "INSERT OR IGNORE INTO processed_wrappers (wrapper_id, wrapper_created_at, transport) VALUES (?1, ?2, ?3)",
287            ).map_err(|e| format!("prepare wrapper ledger: {e}"))?;
288            if let Err(e) = stmt.execute(rusqlite::params![
289                &wrapper_id[..], *wrapper_created_at as i64, super::wrappers::TRANSPORT_NIP17,
290            ]) {
291                crate::log_warn!("[DB] batch wrapper ledger {}: {}", &row.message.id[..8.min(row.message.id.len())], e);
292            }
293        }
294        tx.execute_batch("RELEASE batch_row").map_err(|e| format!("batch release: {e}"))?;
295    }
296    tx.commit().map_err(|e| format!("batch commit: {e}"))?;
297    Ok(saved)
298}
299
300/// Save many messages for one chat in a SINGLE transaction — the bulk-sync persist path
301/// (community backfill pages, negentropy catch-up). One commit amortizes the per-transaction
302/// WAL overhead across the whole page instead of paying it per message.
303///
304/// Structure mirrors `save_message` exactly: contents are encrypted FIRST (encryption is
305/// async and can't run inside the sync transaction), then one transaction writes every event
306/// row + its attachment rows + its reaction rows (kind-7 content is never encrypted at rest,
307/// so reactions are tx-safe).
308///
309/// `session`: phase 1 awaits through encryption + id resolution, so a swap can land inside
310/// it — when provided, the guard is re-checked between the phases and a stale batch is
311/// dropped before it can write into the next account's DB.
312pub async fn save_messages_batch(
313    chat_id: &str,
314    messages: &[&Message],
315    session: Option<&crate::state::SessionGuard>,
316) -> Result<usize, String> {
317    if messages.is_empty() {
318        return Ok(0);
319    }
320    let with_wrappers: Vec<(&Message, Option<([u8; 32], u64)>)> =
321        messages.iter().map(|m| (*m, None)).collect();
322    let mut rows = Vec::with_capacity(messages.len());
323    prepare_batch_rows(chat_id, &with_wrappers, &mut rows).await?;
324    if session.is_some_and(|s| !s.is_valid()) {
325        return Ok(0);
326    }
327    write_batch_rows(&rows)
328}
329
330/// Multi-chat variant for the DM sync stream: gift-wrapped messages span many contacts, and
331/// splitting per chat would give back most of the batching win. Each message may carry its
332/// gift-wrap ledger entry, committed in the SAME transaction right after its row (see
333/// `BatchRow::wrapper`). Groups keep their slice order; everything lands in ONE transaction.
334pub async fn save_messages_batch_multi(
335    groups: &[(String, Vec<(&Message, Option<([u8; 32], u64)>)>)],
336    session: Option<&crate::state::SessionGuard>,
337) -> Result<usize, String> {
338    let total: usize = groups.iter().map(|(_, m)| m.len()).sum();
339    if total == 0 {
340        return Ok(0);
341    }
342    let mut rows = Vec::with_capacity(total);
343    for (chat_id, messages) in groups {
344        prepare_batch_rows(chat_id, messages, &mut rows).await?;
345    }
346    if session.is_some_and(|s| !s.is_valid()) {
347        return Ok(0);
348    }
349    write_batch_rows(&rows)
350}
351
352/// Convert a Message to a StoredEvent.
353fn message_to_stored_event(message: &Message, chat_id: i64, user_id: Option<i64>) -> StoredEvent {
354    let kind = if !message.attachments.is_empty() {
355        event_kind::FILE_ATTACHMENT
356    } else {
357        event_kind::PRIVATE_DIRECT_MESSAGE
358    };
359
360    let mut tags: Vec<Vec<String>> = Vec::new();
361
362    // Millisecond precision tag
363    let ms = message.at % 1000;
364    if ms > 0 {
365        tags.push(vec!["ms".to_string(), ms.to_string()]);
366    }
367
368    // Reply reference
369    if !message.replied_to.is_empty() {
370        tags.push(vec![
371            "e".to_string(),
372            message.replied_to.clone(),
373            "".to_string(),
374            "reply".to_string(),
375        ]);
376    }
377
378    // Attachments are stored in the dedicated `attachments` table (see save_message), not a tag.
379
380    // NIP-30 emoji tags — persist so reload from DB still renders the
381    // custom emoji image instead of the literal `:shortcode:`.
382    for et in &message.emoji_tags {
383        tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
384    }
385
386    // Bot routing targets (npubs) — persist so the passive "ran /cmd with
387    // Bot" render survives a reload.
388    for npub in &message.addressed_bots {
389        tags.push(vec!["bot".to_string(), npub.clone()]);
390    }
391
392    // NIP-40 self-destruct expiry — persist so the countdown + purge survive a
393    // reload. Rides the same tags column as every other message-shaped tag.
394    if let Some(exp) = message.expiration {
395        tags.push(vec!["expiration".to_string(), exp.to_string()]);
396    }
397
398    let preview_metadata = message.preview_metadata.as_ref()
399        .and_then(|m| serde_json::to_string(m).ok());
400
401    StoredEvent {
402        id: message.id.clone(),
403        kind,
404        chat_id,
405        user_id,
406        content: message.content.clone(),
407        tags,
408        reference_id: None,
409        created_at: message.at / 1000,
410        received_at: std::time::SystemTime::now()
411            .duration_since(std::time::UNIX_EPOCH)
412            .map(|d| d.as_millis() as u64)
413            .unwrap_or(0),
414        mine: message.mine,
415        pending: message.pending,
416        failed: message.failed,
417        wrapper_event_id: message.wrapper_event_id.clone(),
418        npub: message.npub.clone(),
419        preview_metadata,
420    }
421}
422
423/// Save a PIVX payment event, resolving chat_id from conversation identifier.
424pub async fn save_pivx_payment_event(
425    conversation_id: &str,
426    mut event: StoredEvent,
427) -> Result<(), String> {
428    event.chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
429    save_event(&event).await
430}
431
432/// Save a system event (member joined/left/removed) with dedup.
433/// Returns true if inserted, false if duplicate.
434pub async fn save_system_event_by_id(
435    event_id: &str,
436    conversation_id: &str,
437    event_type: crate::stored_event::SystemEventType,
438    member_npub: &str,
439    member_name: Option<&str>,
440) -> Result<bool, String> {
441    let now_secs = std::time::SystemTime::now()
442        .duration_since(std::time::UNIX_EPOCH)
443        .map(|d| d.as_secs()).unwrap_or(0);
444    save_system_event_at(event_id, conversation_id, event_type, member_npub, member_name, now_secs, None, None).await
445}
446
447/// Like [`save_system_event_by_id`] but stamps `created_at` from the event's own authenticated timestamp
448/// (clamped to not exceed local now, since the inner author sets it) so a HISTORICALLY-synced presence
449/// (join/leave) sorts at the time it happened, not at ingest-time now. `received_at` stays local now.
450pub async fn save_system_event_at(
451    event_id: &str,
452    conversation_id: &str,
453    event_type: crate::stored_event::SystemEventType,
454    member_npub: &str,
455    member_name: Option<&str>,
456    created_at_secs: u64,
457    // Join attribution (public invites): who minted the link the member joined via, and its label.
458    // Stored as queryable tags so per-link join counts fall out of a tag scan.
459    invited_by: Option<&str>,
460    invited_label: Option<&str>,
461) -> Result<bool, String> {
462    // A blank conversation id is a caller bug, never a conversation. Left to
463    // `get_or_create_chat_id` it MINTS a chat row keyed by "" — and since the
464    // identifier is UNIQUE, every such write from every community collapses into
465    // one phantom row that nothing can open and the boot sweep can only report.
466    // A blank conversation id is a caller bug, never a conversation. Left to
467    // `get_or_create_chat_id` it MINTS a chat row keyed by "" — and since the
468    // identifier is UNIQUE, every such write from every community collapses into
469    // one phantom row that nothing can open and the boot sweep can only report.
470    if conversation_id.trim().is_empty() {
471        return Err("system event has no conversation id".to_string());
472    }
473    let chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
474
475    let now_secs = std::time::SystemTime::now()
476        .duration_since(std::time::UNIX_EPOCH)
477        .map(|d| d.as_secs()).unwrap_or(0);
478    // Author-set timestamp: clamp forward so a future-dated event can't jump ahead of real activity.
479    let created_at = created_at_secs.min(now_secs);
480
481    let display_name = member_name.unwrap_or(member_npub);
482    let content = event_type.display_message(display_name);
483
484    let mut tags: Vec<Vec<String>> = vec![
485        vec!["d".to_string(), "system-event".to_string()],
486        vec!["event-type".to_string(), event_type.as_u8().to_string()],
487        vec!["member".to_string(), member_npub.to_string()],
488    ];
489    if let Some(by) = invited_by {
490        tags.push(vec!["invited-by".to_string(), by.to_string()]);
491        if let Some(l) = invited_label.filter(|l| !l.is_empty()) {
492            tags.push(vec!["invited-label".to_string(), l.to_string()]);
493        }
494    }
495    let tags_json = serde_json::to_string(&tags)
496        .map_err(|e| format!("Failed to serialize tags: {}", e))?;
497
498    let conn = super::get_write_connection_guard_static()?;
499    let rows = conn.execute(
500        r#"INSERT OR IGNORE INTO events (
501            id, kind, chat_id, user_id, content, tags, reference_id,
502            created_at, received_at, mine, pending, failed, wrapper_event_id, npub
503        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
504        rusqlite::params![
505            event_id,
506            event_kind::APPLICATION_SPECIFIC as i32,
507            chat_id, None::<i64>, content, tags_json, None::<String>,
508            created_at as i64, now_secs as i64,
509            0, 0, 0, None::<String>, member_npub,
510        ],
511    ).map_err(|e| format!("Failed to save system event: {}", e))?;
512
513    Ok(rows > 0)
514}
515
516/// Save a message edit as a kind=16 event referencing the original message.
517pub async fn save_edit_event(
518    edit_id: &str,
519    message_id: &str,
520    new_content: &str,
521    emoji_tags: &[crate::types::EmojiTag],
522    chat_id: i64,
523    user_id: Option<i64>,
524    npub: &str,
525) -> Result<(), String> {
526    let now = std::time::SystemTime::now()
527        .duration_since(std::time::UNIX_EPOCH).unwrap();
528
529    // Carry NIP-30 emoji tags so a reload renders the edit's custom emoji image
530    // (the reload fold reads the latest edit's tags, not the original message's).
531    let mut tags = vec![
532        vec!["e".to_string(), message_id.to_string(), "".to_string(), "edit".to_string()],
533    ];
534    for et in emoji_tags {
535        tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
536    }
537
538    let event = StoredEvent {
539        id: edit_id.to_string(),
540        kind: event_kind::MESSAGE_EDIT,
541        chat_id,
542        user_id,
543        content: new_content.to_string(),
544        tags,
545        reference_id: Some(message_id.to_string()),
546        created_at: now.as_secs(),
547        received_at: now.as_millis() as u64,
548        mine: true,
549        pending: false,
550        failed: false,
551        wrapper_event_id: None,
552        npub: Some(npub.to_string()),
553        preview_metadata: None,
554    };
555
556    save_event(&event).await
557}
558
559/// Delete an event from the events table by ID.
560pub async fn delete_event(event_id: &str) -> Result<(), String> {
561    let conn = super::get_write_connection_guard_static()?;
562    // If this row is a chat's read marker, retreat it to the newest surviving event before it FIRST.
563    // A deleted marker would leave `last_read` dangling and collapse the unread anchor (badge stuck
564    // at 99+). The UPDATE fires only when this chat's marker is exactly the row being deleted.
565    if let Ok(Some((chat_row, at))) = conn.query_row(
566        "SELECT chat_id, created_at FROM events WHERE id = ?1",
567        rusqlite::params![event_id],
568        |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
569    ).optional() {
570        conn.execute(
571            "UPDATE chats SET last_read = COALESCE(( \
572                 SELECT id FROM events WHERE chat_id = ?1 AND id != ?2 AND created_at <= ?3 \
573                 ORDER BY created_at DESC, id DESC LIMIT 1), '') \
574             WHERE id = ?1 AND last_read = ?2",
575            rusqlite::params![chat_row, event_id, at],
576        ).map_err(|e| format!("read-marker retreat: {e}"))?;
577    }
578    conn.execute(
579        "DELETE FROM events WHERE id = ?1",
580        rusqlite::params![event_id],
581    ).map_err(|e| format!("Failed to delete event: {}", e))?;
582    Ok(())
583}
584
585/// The stored author (npub) of an event, or `None` if the row (or DB) is absent. Lets the
586/// out-of-window moderation-hide path authorize against a paged-out message's real author.
587pub fn event_author(event_id: &str) -> Result<Option<String>, String> {
588    let conn = match super::get_db_connection_guard_static() {
589        Ok(c) => c,
590        Err(_) => return Ok(None),
591    };
592    conn.query_row(
593        "SELECT npub FROM events WHERE id = ?1",
594        rusqlite::params![event_id],
595        |row| row.get::<_, Option<String>>(0),
596    )
597    .optional()
598    .map(|o| o.flatten())
599    .map_err(|e| format!("Failed to read event author: {}", e))
600}
601
602/// The owning chat identifier, `mine` flag, and stored author (npub) of an event, or
603/// `None` if the row (or DB) is absent. Lets delete-affordance resolution give paged-out
604/// rows the same verdict as resident ones — residency is a cache detail, not a verdict.
605pub fn event_delete_context(event_id: &str) -> Result<Option<(String, bool, Option<String>)>, String> {
606    let conn = match super::get_db_connection_guard_static() {
607        Ok(c) => c,
608        Err(_) => return Ok(None),
609    };
610    conn.query_row(
611        "SELECT c.chat_identifier, e.mine, e.npub \
612         FROM events e JOIN chats c ON c.id = e.chat_id \
613         WHERE e.id = ?1",
614        rusqlite::params![event_id],
615        |row| {
616            Ok((
617                row.get::<_, String>(0)?,
618                row.get::<_, i32>(1)? != 0,
619                row.get::<_, Option<String>>(2)?,
620            ))
621        },
622    )
623    .optional()
624    .map_err(|e| format!("Failed to read event delete context: {}", e))
625}
626
627/// Check if a message/event exists in the database. Returns false if DB unavailable.
628pub fn message_exists_in_db(message_id: &str) -> Result<bool, String> {
629    let conn = match super::get_db_connection_guard_static() {
630        Ok(c) => c,
631        Err(_) => return Ok(false),
632    };
633    conn.query_row(
634        "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",
635        rusqlite::params![message_id],
636        |row| row.get(0),
637    ).map_err(|e| format!("Failed to check event existence: {}", e))
638}
639
640/// Check if a wrapper (giftwrap) event ID exists. Returns false if DB unavailable.
641pub fn wrapper_event_exists(wrapper_event_id: &str) -> Result<bool, String> {
642    let conn = match super::get_db_connection_guard_static() {
643        Ok(c) => c,
644        Err(_) => return Ok(false),
645    };
646    conn.query_row(
647        "SELECT EXISTS(SELECT 1 FROM events WHERE wrapper_event_id = ?1)",
648        rusqlite::params![wrapper_event_id],
649        |row| row.get(0),
650    ).map_err(|e| format!("Failed to check wrapper event existence: {}", e))
651}
652
653/// Update the wrapper event ID for an existing event.
654/// Returns true if updated, false if event already had a wrapper_id.
655pub fn update_wrapper_event_id(event_id: &str, wrapper_event_id: &str) -> Result<bool, String> {
656    let conn = match super::get_write_connection_guard_static() {
657        Ok(c) => c,
658        Err(_) => return Ok(false),
659    };
660    let rows = conn.execute(
661        "UPDATE events SET wrapper_event_id = ?1 WHERE id = ?2 AND (wrapper_event_id IS NULL OR wrapper_event_id = '')",
662        rusqlite::params![wrapper_event_id, event_id],
663    ).map_err(|e| format!("Failed to update wrapper event ID: {}", e))?;
664    Ok(rows > 0)
665}
666
667/// Get message count for a chat.
668pub fn get_chat_message_count(chat_id: i64) -> Result<usize, String> {
669    let conn = super::get_db_connection_guard_static()?;
670    // Must count the SAME kinds get_message_views returns (community chat 9, DM 14, file 15). A
671    // narrower set under-counts vs. the rows actually loaded, which latches the frontend cache's
672    // `isFullyLoaded` flag true and wedges the local back-pager — community channels then never
673    // page DB history past the first screen.
674    let count: i64 = conn.query_row(
675        &format!(
676            "SELECT COUNT(*) FROM events WHERE chat_id = ?1 AND kind IN ({}, {}, {})",
677            event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT
678        ),
679        rusqlite::params![chat_id],
680        |row| row.get(0),
681    ).map_err(|e| format!("Failed to count messages: {}", e))?;
682    Ok(count as usize)
683}
684
685/// Get PIVX payment events for a chat.
686pub fn get_pivx_payments_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
687    let conn = super::get_db_connection_guard_static()?;
688    let chat_id: i64 = conn.query_row(
689        "SELECT id FROM chats WHERE chat_identifier = ?1",
690        rusqlite::params![conversation_id], |row| row.get(0)
691    ).map_err(|_| "Chat not found")?;
692
693    let mut stmt = conn.prepare(
694        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
695         created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
696         FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
697    ).map_err(|e| format!("Failed to prepare: {}", e))?;
698
699    let rows = stmt.query_map(
700        rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
701        |row| {
702            let tags_json: String = row.get(5)?;
703            let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
704            Ok(StoredEvent {
705                id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
706                chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
707                tags, reference_id: row.get(6)?,
708                created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
709                mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
710                failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
711                npub: row.get(13)?, preview_metadata: None,
712            })
713        }
714    ).map_err(|e| format!("Failed to query: {}", e))?;
715
716    let mut payments = Vec::new();
717    for row in rows {
718        let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
719        if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "pivx-payment") {
720            payments.push(event);
721        }
722    }
723    Ok(payments)
724}
725
726/// Get system events (member joined/left) for a chat.
727pub fn get_system_events_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
728    let conn = super::get_db_connection_guard_static()?;
729    let chat_id: i64 = conn.query_row(
730        "SELECT id FROM chats WHERE chat_identifier = ?1",
731        rusqlite::params![conversation_id], |row| row.get(0)
732    ).map_err(|_| "Chat not found")?;
733
734    let mut stmt = conn.prepare(
735        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
736         created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
737         FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
738    ).map_err(|e| format!("Failed to prepare: {}", e))?;
739
740    let rows = stmt.query_map(
741        rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
742        |row| {
743            let tags_json: String = row.get(5)?;
744            let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
745            Ok(StoredEvent {
746                id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
747                chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
748                tags, reference_id: row.get(6)?,
749                created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
750                mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
751                failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
752                npub: row.get(13)?, preview_metadata: None,
753            })
754        }
755    ).map_err(|e| format!("Failed to query: {}", e))?;
756
757    let mut events = Vec::new();
758    for row in rows {
759        let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
760        if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "system-event") {
761            events.push(event);
762        }
763    }
764    Ok(events)
765}
766
767// ============================================================================
768// Event Read Operations
769// ============================================================================
770
771/// Helper to parse a SQLite row into a StoredEvent.
772fn parse_event_row(row: &rusqlite::Row) -> rusqlite::Result<StoredEvent> {
773    let tags_json: String = row.get(5)?;
774    let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
775
776    Ok(StoredEvent {
777        id: row.get(0)?,
778        kind: row.get::<_, i32>(1)? as u16,
779        chat_id: row.get(2)?,
780        user_id: row.get(3)?,
781        content: row.get(4)?,
782        tags,
783        reference_id: row.get(6)?,
784        created_at: row.get::<_, i64>(7)? as u64,
785        received_at: row.get::<_, i64>(8)? as u64,
786        mine: row.get::<_, i32>(9)? != 0,
787        pending: row.get::<_, i32>(10)? != 0,
788        failed: row.get::<_, i32>(11)? != 0,
789        wrapper_event_id: row.get(12)?,
790        npub: row.get(13)?,
791        preview_metadata: row.get(14)?,
792    })
793}
794
795/// Get events for a chat with pagination, optionally filtered by kind.
796/// Message/edit content is decrypted via maybe_decrypt.
797pub async fn get_events(
798    chat_id: i64,
799    kinds: Option<&[u16]>,
800    limit: usize,
801    offset: usize,
802) -> Result<Vec<StoredEvent>, String> {
803    let events: Vec<StoredEvent> = {
804        let conn = super::get_db_connection_guard_static()?;
805
806        if let Some(k) = kinds {
807            let kind_placeholders: String = (0..k.len())
808                .map(|i| format!("?{}", i + 2))
809                .collect::<Vec<_>>()
810                .join(",");
811            let limit_param = k.len() + 2;
812            let offset_param = k.len() + 3;
813
814            let sql = format!(
815                "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
816                 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
817                 FROM events WHERE chat_id = ?1 AND kind IN ({}) \
818                 ORDER BY created_at DESC, received_at DESC \
819                 LIMIT ?{} OFFSET ?{}",
820                kind_placeholders, limit_param, offset_param
821            );
822
823            let mut stmt = conn.prepare(&sql)
824                .map_err(|e| format!("Failed to prepare events query: {}", e))?;
825
826            match k.len() {
827                1 => {
828                    let rows = stmt.query_map(
829                        rusqlite::params![chat_id, k[0] as i32, limit as i64, offset as i64],
830                        parse_event_row
831                    ).map_err(|e| format!("Failed to query events: {}", e))?;
832                    rows.filter_map(|r| r.ok()).collect()
833                },
834                2 => {
835                    let rows = stmt.query_map(
836                        rusqlite::params![chat_id, k[0] as i32, k[1] as i32, limit as i64, offset as i64],
837                        parse_event_row
838                    ).map_err(|e| format!("Failed to query events: {}", e))?;
839                    rows.filter_map(|r| r.ok()).collect()
840                },
841                3 => {
842                    let rows = stmt.query_map(
843                        rusqlite::params![chat_id, k[0] as i32, k[1] as i32, k[2] as i32, limit as i64, offset as i64],
844                        parse_event_row
845                    ).map_err(|e| format!("Failed to query events: {}", e))?;
846                    rows.filter_map(|r| r.ok()).collect()
847                },
848                _ => return Err("Unsupported number of kinds".to_string()),
849            }
850        } else {
851            let mut stmt = conn.prepare(
852                "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
853                 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
854                 FROM events WHERE chat_id = ?1 \
855                 ORDER BY created_at DESC, received_at DESC \
856                 LIMIT ?2 OFFSET ?3"
857            ).map_err(|e| format!("Failed to prepare events query: {}", e))?;
858
859            let rows = stmt.query_map(
860                rusqlite::params![chat_id, limit as i64, offset as i64],
861                parse_event_row
862            ).map_err(|e| format!("Failed to query events: {}", e))?;
863            rows.filter_map(|r| r.ok()).collect()
864        }
865    };
866
867    // Decrypt message content
868    let mut decrypted = Vec::with_capacity(events.len());
869    for mut event in events {
870        if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
871            event.content = crate::crypto::maybe_decrypt(event.content).await
872                .unwrap_or_else(|_| "[Decryption failed]".to_string());
873        }
874        decrypted.push(event);
875    }
876
877    Ok(decrypted)
878}
879
880/// Get events that reference specific message IDs (reactions, edits).
881pub async fn get_related_events(
882    reference_ids: &[String],
883) -> Result<Vec<StoredEvent>, String> {
884    if reference_ids.is_empty() {
885        return Ok(Vec::new());
886    }
887
888    let conn = super::get_db_connection_guard_static()?;
889
890    let placeholders: String = reference_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
891    let sql = format!(
892        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
893         created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
894         FROM events WHERE reference_id IN ({}) \
895         ORDER BY created_at ASC, received_at ASC",
896        placeholders
897    );
898
899    let mut stmt = conn.prepare(&sql)
900        .map_err(|e| format!("Failed to prepare related events query: {}", e))?;
901
902    let params: Vec<&dyn rusqlite::ToSql> = reference_ids.iter()
903        .map(|s| s as &dyn rusqlite::ToSql)
904        .collect();
905
906    let events: Vec<StoredEvent> = stmt.query_map(params.as_slice(), parse_event_row)
907        .map_err(|e| format!("Failed to query related events: {}", e))?
908        .filter_map(|r| r.ok())
909        .collect();
910
911    Ok(events)
912}
913
914/// Context data for a replied-to message.
915pub struct ReplyContext {
916    pub content: String,
917    pub npub: Option<String>,
918    pub has_attachment: bool,
919    /// Extension of the attachment, when the replied-to message is a file, so
920    /// the reply quote can label the type even when the target is off-screen.
921    pub extension: Option<String>,
922}
923
924/// Fetch reply context for a list of message IDs.
925pub async fn get_reply_contexts(
926    message_ids: &[String],
927) -> Result<std::collections::HashMap<String, ReplyContext>, String> {
928    use std::collections::HashMap;
929
930    if message_ids.is_empty() {
931        return Ok(HashMap::new());
932    }
933
934    let (events, edits): (Vec<(String, i32, String, Option<String>, Option<String>)>, Vec<(String, String)>) = {
935        let conn = super::get_db_connection_guard_static()?;
936
937        let placeholders: String = (0..message_ids.len())
938            .map(|i| format!("?{}", i + 1))
939            .collect::<Vec<_>>()
940            .join(",");
941
942        // Query original messages (tags carry the file-type/name for attachment quotes)
943        let sql = format!(
944            "SELECT id, kind, content, npub, tags FROM events WHERE id IN ({})",
945            placeholders
946        );
947        let mut stmt = conn.prepare(&sql)
948            .map_err(|e| format!("Failed to prepare reply context query: {}", e))?;
949
950        let params: Vec<&str> = message_ids.iter().map(|s| s.as_str()).collect();
951        let params_dyn: Vec<&dyn rusqlite::ToSql> = params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
952
953        let rows = stmt.query_map(params_dyn.as_slice(), |row| {
954            Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?,
955                row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?,
956                row.get::<_, Option<String>>(4)?))
957        }).map_err(|e| format!("Failed to query reply contexts: {}", e))?;
958        let events_result: Vec<_> = rows.filter_map(|r| r.ok()).collect();
959        drop(stmt);
960
961        // Query latest edits
962        let edit_sql = format!(
963            "SELECT reference_id, content FROM events \
964             WHERE kind = {} AND reference_id IN ({}) \
965             ORDER BY created_at DESC, received_at DESC",
966            event_kind::MESSAGE_EDIT, placeholders
967        );
968        let mut edit_stmt = conn.prepare(&edit_sql)
969            .map_err(|e| format!("Failed to prepare edit query: {}", e))?;
970        let edit_rows = edit_stmt.query_map(params_dyn.as_slice(), |row| {
971            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
972        }).map_err(|e| format!("Failed to query edits: {}", e))?;
973        let edits_result: Vec<_> = edit_rows.filter_map(|r| r.ok()).collect();
974
975        (events_result, edits_result)
976    };
977
978    // Build latest edit map (first = most recent since ordered DESC)
979    let mut latest_edits: HashMap<String, String> = HashMap::new();
980    for (ref_id, content) in edits {
981        latest_edits.entry(ref_id).or_insert(content);
982    }
983
984    // Batch the file replies' attachments (one query, not one per reply) for the extension below.
985    let file_ids: Vec<String> = events.iter()
986        .filter(|(_, kind, _, _, _)| *kind == event_kind::FILE_ATTACHMENT as i32)
987        .map(|(id, _, _, _, _)| id.clone())
988        .collect();
989    let atts_by_event = super::attachments::get_attachments_for_events(&file_ids).unwrap_or_default();
990
991    // Decrypt and build contexts
992    let mut contexts = HashMap::new();
993    for (id, kind, original_content, npub, tags) in events {
994        let has_attachment = kind == event_kind::FILE_ATTACHMENT as i32;
995        let content_to_decrypt = latest_edits.get(&id).cloned().unwrap_or(original_content);
996
997        let decrypted_content = if kind == event_kind::CHAT_MESSAGE as i32
998            || kind == event_kind::PRIVATE_DIRECT_MESSAGE as i32
999        {
1000            crate::crypto::maybe_decrypt(content_to_decrypt).await
1001                .unwrap_or_else(|_| "[Decryption failed]".to_string())
1002        } else {
1003            String::new()
1004        };
1005
1006        // The first attachment's extension lets the quote show the file type. From the table, with a
1007        // legacy-tag fallback for an un-backfilled pre-migration row.
1008        let extension = if has_attachment {
1009            atts_by_event.get(&id)
1010                .and_then(|atts| atts.first())
1011                .map(|a| a.extension.to_lowercase())
1012                .filter(|e| !e.is_empty())
1013                .or_else(|| tags.as_deref()
1014                    .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
1015                    .and_then(|parsed| parsed.into_iter()
1016                        .find(|t| t.first().map(|k| k == "attachments").unwrap_or(false))
1017                        .and_then(|t| t.into_iter().nth(1)))
1018                    .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(&json).ok())
1019                    .and_then(|atts| atts.into_iter().next())
1020                    .and_then(|a| a.get("extension").and_then(|e| e.as_str()).map(str::to_lowercase))
1021                    .filter(|e| !e.is_empty()))
1022        } else {
1023            None
1024        };
1025
1026        contexts.insert(id, ReplyContext { content: decrypted_content, npub, has_attachment, extension });
1027    }
1028
1029    Ok(contexts)
1030}
1031
1032/// Populate reply context for a PAGE of messages in one query — the sync/back-page
1033/// counterpart to [`populate_reply_context`]. Every path that emits a message straight to
1034/// the UI must resolve the quote first: the frontend renders the emitted payload, and a
1035/// reply whose parent lies outside the rendered window has no other source for it (a
1036/// parent inside the window is resolved from memory instead). Messages whose parent isn't
1037/// persisted yet are left untouched.
1038pub async fn populate_reply_contexts(messages: Vec<&mut Message>) -> Result<(), String> {
1039    let ids: Vec<String> = messages
1040        .iter()
1041        .filter(|m| !m.replied_to.is_empty())
1042        .map(|m| m.replied_to.clone())
1043        .collect();
1044    if ids.is_empty() {
1045        return Ok(());
1046    }
1047    let contexts = get_reply_contexts(&ids).await?;
1048    for message in messages {
1049        if let Some(ctx) = contexts.get(&message.replied_to) {
1050            message.replied_to_content = Some(ctx.content.clone());
1051            message.replied_to_npub = ctx.npub.clone();
1052            message.replied_to_has_attachment = Some(ctx.has_attachment);
1053            message.replied_to_attachment_extension = ctx.extension.clone();
1054        }
1055    }
1056    Ok(())
1057}
1058
1059/// Populate reply context for a single message.
1060/// Used for real-time messages that don't go through get_message_views.
1061pub async fn populate_reply_context(message: &mut Message) -> Result<(), String> {
1062    if message.replied_to.is_empty() {
1063        return Ok(());
1064    }
1065
1066    let contexts = get_reply_contexts(&[message.replied_to.clone()]).await?;
1067
1068    if let Some(ctx) = contexts.get(&message.replied_to) {
1069        message.replied_to_content = Some(ctx.content.clone());
1070        message.replied_to_npub = ctx.npub.clone();
1071        message.replied_to_has_attachment = Some(ctx.has_attachment);
1072        message.replied_to_attachment_extension = ctx.extension.clone();
1073    }
1074
1075    Ok(())
1076}
1077
1078/// Whether `event_id` is one of our own messages (`mine = 1`). A reply to our
1079/// own message is an implicit ping, so notifications treat it like a direct
1080/// @mention (breaks through a muted channel). Missing row → not ours → false.
1081pub fn is_own_event(event_id: &str) -> bool {
1082    let Ok(conn) = super::get_db_connection_guard_static() else {
1083        return false;
1084    };
1085    conn.query_row(
1086        "SELECT mine FROM events WHERE id = ?1",
1087        [event_id],
1088        |row| row.get::<_, i64>(0),
1089    )
1090    .map(|mine| mine == 1)
1091    .unwrap_or(false)
1092}
1093
1094// ============================================================================
1095// Message Views — compose full Messages from events + reactions + edits
1096// ============================================================================
1097
1098/// Extract a single tag value from raw tags JSON without full allocation.
1099fn extract_tag_from_json(tags_json: &str, key: &str) -> Option<String> {
1100    if tags_json.len() <= 2 { return None; }
1101    let pattern = format!("[\"{}\"", key);
1102    if !tags_json.contains(&pattern) { return None; }
1103    let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1104    tags.into_iter()
1105        .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
1106        .and_then(|tag| tag.into_iter().nth(1))
1107}
1108
1109
1110/// A stored reaction author written as 64-char hex (an early v2 ingest) reads
1111/// back as the npub the frontend contract expects — self-heals old rows with no
1112/// migration; a bech32 or unknown value passes through untouched.
1113fn normalize_reaction_author(author: String) -> String {
1114    if author.len() == 64 && author.bytes().all(|b| b.is_ascii_hexdigit()) {
1115        if let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(&author) {
1116            use nostr_sdk::prelude::ToBech32;
1117            let Ok(npub) = pk.to_bech32();
1118            return npub;
1119        }
1120    }
1121    author
1122}
1123/// Extract the NIP-30 `["emoji", shortcode, url]` URL from a stored
1124/// reaction's tags. The reaction's content must be `:shortcode:` form
1125/// and the matching tag's shortcode must agree — otherwise we get the
1126/// URL of a stray emoji tag that doesn't actually represent the
1127/// reaction's chosen emoji.
1128fn extract_reaction_emoji_url(tags: &[Vec<String>], content: &str) -> Option<String> {
1129    if !content.starts_with(':') || !content.ends_with(':') || content.len() < 3 {
1130        return None;
1131    }
1132    let sc = &content[1..content.len() - 1];
1133    tags.iter().find_map(|t| {
1134        if t.len() >= 3 && t[0] == "emoji" && t[1] == sc {
1135            Some(t[2].clone())
1136        } else {
1137            None
1138        }
1139    })
1140}
1141
1142/// Extract a NIP-10 reply reference ("e" tag with "reply" marker at position 3).
1143fn extract_reply_tag_from_json(tags_json: &str) -> Option<String> {
1144    if tags_json.len() <= 2 { return None; }
1145    if !tags_json.contains("[\"e\"") { return None; }
1146    let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1147    tags.into_iter()
1148        .find(|tag| {
1149            tag.first().map(|s| s.as_str()) == Some("e")
1150                && tag.get(3).map(|s| s.as_str()) == Some("reply")
1151        })
1152        .and_then(|tag| tag.into_iter().nth(1))
1153}
1154
1155/// Get message events with reactions, edits, and attachments composed.
1156///
1157/// This is the main "get messages" function. Queries events, fetches related
1158/// reactions/edits, parses attachments, applies edits, resolves reply context.
1159pub async fn get_message_views(
1160    chat_id: i64,
1161    limit: usize,
1162    offset: usize,
1163) -> Result<Vec<Message>, String> {
1164    // Step 1: Get message events (kind 9, 14, 15)
1165    let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1166    let message_events = get_events(chat_id, Some(&message_kinds), limit, offset).await?;
1167
1168    compose_message_views(message_events).await
1169}
1170
1171/// Compose Message views from already-fetched message events (kind 9/14/15):
1172/// fetch related reactions/edits, parse attachments, apply edits, resolve reply
1173/// context. Shared by `get_message_views` (offset pager) and `get_messages_around`
1174/// (anchored window). Input order is preserved in the output.
1175async fn compose_message_views(message_events: Vec<StoredEvent>) -> Result<Vec<Message>, String> {
1176    use std::collections::HashMap;
1177
1178    if message_events.is_empty() {
1179        return Ok(Vec::new());
1180    }
1181
1182    // Step 2: Get related events (reactions, edits)
1183    let message_ids: Vec<String> = message_events.iter().map(|e| e.id.clone()).collect();
1184    let related_events = get_related_events(&message_ids).await?;
1185
1186    let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1187    let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1188
1189    for event in related_events {
1190        if let Some(ref_id) = &event.reference_id {
1191            match event.kind {
1192                k if k == event_kind::REACTION => {
1193                    let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1194                    reactions_by_msg.entry(ref_id.clone()).or_default().push(Reaction {
1195                        id: event.id.clone(),
1196                        reference_id: ref_id.clone(),
1197                        author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1198                        emoji: event.content.clone(),
1199                        emoji_url,
1200                    });
1201                }
1202                k if k == event_kind::MESSAGE_EDIT => {
1203                    let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1204                        .unwrap_or_else(|_| event.content.clone());
1205                    let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1206                    edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1207                }
1208                _ => {}
1209            }
1210        }
1211    }
1212
1213    for edits in edits_by_msg.values_mut() {
1214        edits.sort_by_key(|(ts, _, _)| *ts);
1215    }
1216
1217    // Step 3: Attachments from the dedicated table (batched), with a legacy-tag fallback for any
1218    // file event not represented in the table (an un-backfilled pre-migration row).
1219    let attach_ids: Vec<String> = message_events.iter()
1220        .filter(|e| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1221        .map(|e| e.id.clone())
1222        .collect();
1223    let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1224        .unwrap_or_default();
1225    for event in &message_events {
1226        if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1227            continue;
1228        }
1229        if attachments_by_msg.contains_key(&event.id) {
1230            continue;
1231        }
1232        if let Some(json) = event.get_tag("attachments") {
1233            if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(json) {
1234                if !atts.is_empty() {
1235                    attachments_by_msg.insert(event.id.clone(), atts);
1236                }
1237            }
1238        }
1239    }
1240
1241    // Step 4: Compose Message structs
1242    let mut messages = Vec::with_capacity(message_events.len());
1243    for event in message_events {
1244        let replied_to = event.get_reply_reference().unwrap_or("").to_string();
1245        let at = event.timestamp_ms();
1246        let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1247        let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1248
1249        let original_content = if event.kind == event_kind::FILE_ATTACHMENT {
1250            String::new()
1251        } else {
1252            event.content.clone()
1253        };
1254
1255        // Edits carry their own emoji tags; the newest edit's tags win so the
1256        // displayed (latest) content renders its custom emoji, not the original's.
1257        let original_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1258        let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1259            let mut history = Vec::with_capacity(edits.len() + 1);
1260            history.push(crate::types::EditEntry { content: original_content.clone(), edited_at: at });
1261            for (ts, c, _) in &edits {
1262                history.push(crate::types::EditEntry { content: c.clone(), edited_at: *ts });
1263            }
1264            let (latest, latest_emoji) = edits.last()
1265                .map(|(_, c, e)| (c.clone(), e.clone()))
1266                .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1267            (latest, true, Some(history), latest_emoji)
1268        } else {
1269            (original_content, false, None, original_emoji)
1270        };
1271
1272        let preview_metadata = event.preview_metadata
1273            .and_then(|json| serde_json::from_str(&json).ok());
1274
1275        let addressed_bots = extract_bot_tags(&event.tags);
1276        let expiration = extract_expiration_tag(&event.tags);
1277        messages.push(Message {
1278            expiration,
1279            id: event.id, content, replied_to,
1280            replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1281            replied_to_attachment_extension: None,
1282            preview_metadata, attachments, reactions, at,
1283            pending: event.pending, failed: event.failed, mine: event.mine,
1284            npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1285            edited, edit_history,
1286            emoji_tags,
1287            addressed_bots,
1288        });
1289    }
1290
1291    // Rows whose NIP-40 expiry passed while out of STATE (app closed, chat
1292    // unopened) must never reach a renderer — strip them here, at the single
1293    // DB→Message chokepoint, and purge their remnants in the background.
1294    crate::self_destruct::strip_expired(&mut messages);
1295
1296    // Step 5: Reply context
1297    let reply_ids: Vec<String> = messages.iter()
1298        .filter(|m| !m.replied_to.is_empty())
1299        .map(|m| m.replied_to.clone())
1300        .collect();
1301
1302    if !reply_ids.is_empty() {
1303        let contexts = get_reply_contexts(&reply_ids).await?;
1304        for msg in &mut messages {
1305            if let Some(ctx) = contexts.get(&msg.replied_to) {
1306                msg.replied_to_content = Some(ctx.content.clone());
1307                msg.replied_to_npub = ctx.npub.clone();
1308                msg.replied_to_has_attachment = Some(ctx.has_attachment);
1309                msg.replied_to_attachment_extension = ctx.extension.clone();
1310            }
1311        }
1312    }
1313
1314    Ok(messages)
1315}
1316
1317/// Anchored (random-access) message window: load `before` messages up to and
1318/// including the anchor, plus `after` messages strictly newer than it. O(window)
1319/// regardless of how deep the anchor sits in the chat — unlike the offset pager,
1320/// which is O(depth) to reach a far-back message.
1321///
1322/// Returns ASC by `created_at` (oldest first), composed with reactions/edits/
1323/// attachments. Errs if the anchor id isn't in the DB so the caller can fall back.
1324pub async fn get_messages_around(
1325    chat_id: i64,
1326    anchor_id: &str,
1327    before: usize,
1328    after: usize,
1329) -> Result<Vec<Message>, String> {
1330    let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1331
1332    let message_events: Vec<StoredEvent> = {
1333        let conn = super::get_db_connection_guard_static()?;
1334
1335        // Resolve the anchor's FULL sort key (created_at, received_at, rowid). Paging by created_at
1336        // alone wedges on a wall of equal timestamps (a message burst): the query keeps returning the
1337        // same newest-N of the cluster, so back-paging stalls before reaching older history. The
1338        // (received_at, rowid) tiebreak — rowid being the unique final key — gives a strict total
1339        // order, so every page steps strictly past the previous, through any same-timestamp cluster.
1340        let (anchor_at, anchor_rt, anchor_rowid): (i64, i64, i64) = conn.query_row(
1341            "SELECT created_at, received_at, rowid FROM events WHERE id = ?1",
1342            rusqlite::params![anchor_id],
1343            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1344        ).map_err(|e| format!("Anchor message not found: {}", e))?;
1345
1346        // Kinds occupy ?2..?4; then ?5 created_at, ?6 received_at, ?7 rowid, ?8 limit.
1347        let kind_placeholders: String = (0..message_kinds.len())
1348            .map(|i| format!("?{}", i + 2))
1349            .collect::<Vec<_>>()
1350            .join(",");
1351        let cols = "id, kind, chat_id, user_id, content, tags, reference_id, \
1352                    created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata";
1353
1354        // Older incl. anchor: strict key <= anchor key; newest-first then reverse to ASC.
1355        let older_sql = format!(
1356            "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1357             AND (created_at < ?5 OR (created_at = ?5 AND (received_at < ?6 \
1358                  OR (received_at = ?6 AND rowid <= ?7)))) \
1359             ORDER BY created_at DESC, received_at DESC, rowid DESC LIMIT ?8",
1360            cols, kind_placeholders
1361        );
1362        let mut older_stmt = conn.prepare(&older_sql)
1363            .map_err(|e| format!("Failed to prepare older window query: {}", e))?;
1364        let older_rows = older_stmt.query_map(
1365            rusqlite::params![
1366                chat_id,
1367                message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1368                anchor_at, anchor_rt, anchor_rowid, before as i64
1369            ],
1370            parse_event_row,
1371        ).map_err(|e| format!("Failed to query older window: {}", e))?;
1372        let mut older: Vec<StoredEvent> = older_rows.filter_map(|r| r.ok()).collect();
1373        older.reverse(); // DESC -> ASC
1374
1375        // Newer: strictly after the anchor key.
1376        let newer_sql = format!(
1377            "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1378             AND (created_at > ?5 OR (created_at = ?5 AND (received_at > ?6 \
1379                  OR (received_at = ?6 AND rowid > ?7)))) \
1380             ORDER BY created_at ASC, received_at ASC, rowid ASC LIMIT ?8",
1381            cols, kind_placeholders
1382        );
1383        let mut newer_stmt = conn.prepare(&newer_sql)
1384            .map_err(|e| format!("Failed to prepare newer window query: {}", e))?;
1385        let newer_rows = newer_stmt.query_map(
1386            rusqlite::params![
1387                chat_id,
1388                message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1389                anchor_at, anchor_rt, anchor_rowid, after as i64
1390            ],
1391            parse_event_row,
1392        ).map_err(|e| format!("Failed to query newer window: {}", e))?;
1393        let newer: Vec<StoredEvent> = newer_rows.filter_map(|r| r.ok()).collect();
1394
1395        older.into_iter().chain(newer).collect()
1396    };
1397
1398    // Decrypt message content (mirror get_events).
1399    let mut decrypted = Vec::with_capacity(message_events.len());
1400    for mut event in message_events {
1401        if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
1402            event.content = crate::crypto::maybe_decrypt(event.content).await
1403                .unwrap_or_else(|_| "[Decryption failed]".to_string());
1404        }
1405        decrypted.push(event);
1406    }
1407
1408    compose_message_views(decrypted).await
1409}
1410
1411/// Get the last message for ALL chats in a single batch query.
1412/// Optimized for app startup (chat list sidebar).
1413pub async fn get_all_chats_last_messages() -> Result<std::collections::HashMap<String, Vec<Message>>, String> {
1414    use std::collections::HashMap;
1415
1416    // Step 1: Query last message per chat via correlated subquery
1417    let message_events: Vec<(String, StoredEvent, String)> = {
1418        let conn = super::get_db_connection_guard_static()?;
1419        let mut stmt = conn.prepare(
1420            "SELECT c.chat_identifier, \
1421             e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1422             e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, e.preview_metadata \
1423             FROM chats c JOIN events e ON e.rowid = ( \
1424                 SELECT e2.rowid FROM events e2 WHERE e2.chat_id = c.id \
1425                 AND e2.kind IN (?1, ?2, ?3) \
1426                 ORDER BY e2.created_at DESC, e2.received_at DESC LIMIT 1) \
1427             WHERE c.chat_type != 1"
1428        ).map_err(|e| format!("Failed to prepare: {}", e))?;
1429
1430        let rows = stmt.query_map(
1431            rusqlite::params![
1432                event_kind::CHAT_MESSAGE as i32,
1433                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1434                event_kind::FILE_ATTACHMENT as i32
1435            ],
1436            |row| {
1437                let chat_id: String = row.get(0)?;
1438                let tags_json: String = row.get(6)?;
1439                let event = StoredEvent {
1440                    id: row.get(1)?, kind: row.get::<_, i32>(2)? as u16,
1441                    chat_id: row.get(3)?, user_id: row.get(4)?, content: row.get(5)?,
1442                    tags: Vec::new(), // Deferred — parsed on-demand
1443                    reference_id: row.get(7)?,
1444                    created_at: row.get::<_, i64>(8)? as u64, received_at: row.get::<_, i64>(9)? as u64,
1445                    mine: row.get::<_, i32>(10)? != 0, pending: row.get::<_, i32>(11)? != 0,
1446                    failed: row.get::<_, i32>(12)? != 0, wrapper_event_id: row.get(13)?,
1447                    npub: row.get(14)?, preview_metadata: row.get(15)?,
1448                };
1449                Ok((chat_id, event, tags_json))
1450            }
1451        ).map_err(|e| format!("Failed to query: {}", e))?;
1452        rows.filter_map(|r| r.ok()).collect()
1453    };
1454
1455    if message_events.is_empty() {
1456        return Ok(HashMap::new());
1457    }
1458
1459    // Step 2: Related events (reactions, edits)
1460    let message_ids: Vec<String> = message_events.iter().map(|(_, e, _)| e.id.clone()).collect();
1461    let related_events = get_related_events(&message_ids).await?;
1462
1463    let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1464    let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1465
1466    for event in related_events {
1467        if let Some(ref_id) = &event.reference_id {
1468            match event.kind {
1469                k if k == event_kind::REACTION => {
1470                    let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1471                    reactions_by_msg.entry(ref_id.clone()).or_default().push(Reaction {
1472                        id: event.id.clone(), reference_id: ref_id.clone(),
1473                        author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1474                        emoji: event.content.clone(),
1475                        emoji_url,
1476                    });
1477                }
1478                k if k == event_kind::MESSAGE_EDIT => {
1479                    let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1480                        .unwrap_or_else(|_| event.content.clone());
1481                    let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1482                    edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1483                }
1484                _ => {}
1485            }
1486        }
1487    }
1488    for edits in edits_by_msg.values_mut() {
1489        edits.sort_by_key(|(ts, _, _)| *ts);
1490    }
1491
1492    // Step 3: Attachments from the dedicated table (batched), with a legacy-tag fallback.
1493    let attach_ids: Vec<String> = message_events.iter()
1494        .filter(|(_, e, _)| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1495        .map(|(_, e, _)| e.id.clone())
1496        .collect();
1497    let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1498        .unwrap_or_default();
1499    for (_, event, tags_json) in &message_events {
1500        if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1501            continue;
1502        }
1503        if attachments_by_msg.contains_key(&event.id) {
1504            continue;
1505        }
1506        if let Some(val) = extract_tag_from_json(tags_json, "attachments") {
1507            if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&val) {
1508                if !atts.is_empty() {
1509                    attachments_by_msg.insert(event.id.clone(), atts);
1510                }
1511            }
1512        }
1513    }
1514
1515    // Step 4: Compose Messages grouped by chat_identifier
1516    let mut result: HashMap<String, Vec<Message>> = HashMap::new();
1517
1518    for (chat_identifier, event, tags_json) in message_events {
1519        let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1520        let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1521        let replied_to = extract_reply_tag_from_json(&tags_json).unwrap_or_default();
1522
1523        // Decrypt content
1524        let original_content = if event.kind == event_kind::CHAT_MESSAGE
1525            || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
1526        {
1527            crate::crypto::maybe_decrypt(event.content.clone()).await
1528                .unwrap_or_else(|_| "[Decryption failed]".to_string())
1529        } else {
1530            String::new()
1531        };
1532
1533        let stored_tags = serde_json::from_str::<Vec<Vec<String>>>(&tags_json).unwrap_or_default();
1534        let original_emoji = crate::types::EmojiTag::extract_from_stored(&stored_tags);
1535        let addressed_bots = extract_bot_tags(&stored_tags);
1536        let expiration = extract_expiration_tag(&stored_tags);
1537        // Newest edit's emoji tags win so the latest content renders correctly.
1538        let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1539            let (latest, latest_emoji) = edits.last()
1540                .map(|(_, c, e)| (c.clone(), e.clone()))
1541                .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1542            let history: Vec<crate::types::EditEntry> = std::iter::once(crate::types::EditEntry {
1543                content: original_content, edited_at: event.created_at * 1000,
1544            }).chain(edits.into_iter().map(|(ts, c, _)| crate::types::EditEntry { content: c, edited_at: ts }))
1545            .collect();
1546            (latest, true, Some(history), latest_emoji)
1547        } else {
1548            (original_content, false, None, original_emoji)
1549        };
1550
1551        let preview_metadata = event.preview_metadata
1552            .and_then(|json| serde_json::from_str(&json).ok());
1553
1554        result.entry(chat_identifier).or_default().push(Message {
1555            expiration,
1556            id: event.id, content, replied_to,
1557            replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1558            replied_to_attachment_extension: None,
1559            preview_metadata, attachments, reactions, at: event.created_at * 1000,
1560            pending: event.pending, failed: event.failed, mine: event.mine,
1561            npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1562            edited, edit_history,
1563            emoji_tags,
1564            addressed_bots,
1565        });
1566    }
1567
1568    // Step 5: Reply context — the openChat pre-paint renders this boot last-message
1569    // synchronously (before the richer get_message_views load lands), so without
1570    // context here a reply shows its quote only on the second open.
1571    let reply_ids: Vec<String> = result.values()
1572        .flatten()
1573        .filter(|m| !m.replied_to.is_empty())
1574        .map(|m| m.replied_to.clone())
1575        .collect();
1576
1577    if !reply_ids.is_empty() {
1578        let contexts = get_reply_contexts(&reply_ids).await?;
1579        for msg in result.values_mut().flatten() {
1580            if let Some(ctx) = contexts.get(&msg.replied_to) {
1581                msg.replied_to_content = Some(ctx.content.clone());
1582                msg.replied_to_npub = ctx.npub.clone();
1583                msg.replied_to_has_attachment = Some(ctx.has_attachment);
1584                msg.replied_to_attachment_extension = ctx.extension.clone();
1585            }
1586        }
1587    }
1588
1589    // An expired self-destruct as a chat's last message must not flash in the
1590    // chat list — strip + background-purge; the preview shows empty until the
1591    // next boot resolves the prior message, same as a mid-session sweep.
1592    for msgs in result.values_mut() {
1593        crate::self_destruct::strip_expired(msgs);
1594    }
1595
1596    Ok(result)
1597}
1598
1599/// Per-chat unread count, computed straight from the DB so it's correct even when only the last
1600/// message is in RAM (the boot state). Mirrors the in-memory walk-back exactly: unread = non-mine
1601/// messages newer than the most recent "anchor" (our own message OR the `last_read` marker,
1602/// whichever is latest). A never-read chat (empty `last_read`, no own message) counts all its
1603/// non-mine messages. Returns `chat_identifier → count`; chats with 0 unread are omitted.
1604/// Muted/blocked filtering is left to the caller (it lives in RAM state, cheaply).
1605pub async fn unread_counts() -> Result<std::collections::HashMap<String, u32>, String> {
1606    let conn = super::get_db_connection_guard_static()?;
1607    // Anchor computed once per chat in the CTE so the count scan doesn't re-derive it per row. The
1608    // anchor filter rides the LEFT JOIN's ON clause so a never-read chat still yields a row (anchor
1609    // 0 via COALESCE) and counts all its messages; in WHERE it would drop those chats. The
1610    // `last_read` anchor is kind-agnostic: a "read to here" marker can land on a system event (kind
1611    // 30078) and must still cut the count by its timestamp. Only the own-message anchor is kind-filtered.
1612    let mut stmt = conn
1613        .prepare(
1614            "WITH anchors AS ( \
1615                SELECT c.id AS chat_id, c.chat_identifier AS chat_identifier, \
1616                       COALESCE(MAX(e.created_at), 0) AS anchor_ts \
1617                FROM chats c \
1618                LEFT JOIN events e ON e.chat_id = c.id \
1619                  AND ((e.mine = 1 AND e.kind IN (?1, ?2, ?3)) OR e.id = c.last_read) \
1620                GROUP BY c.id \
1621             ) \
1622             SELECT a.chat_identifier, COUNT(*) AS unread \
1623             FROM events e JOIN anchors a ON a.chat_id = e.chat_id \
1624             WHERE e.kind IN (?1, ?2, ?3) AND e.mine = 0 AND e.created_at > a.anchor_ts \
1625             GROUP BY a.chat_identifier",
1626        )
1627        .map_err(|e| format!("prepare unread_counts: {e}"))?;
1628    let rows = stmt
1629        .query_map(
1630            rusqlite::params![
1631                event_kind::CHAT_MESSAGE as i32,
1632                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1633                event_kind::FILE_ATTACHMENT as i32
1634            ],
1635            |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)),
1636        )
1637        .map_err(|e| format!("query unread_counts: {e}"))?;
1638    let mut out = std::collections::HashMap::new();
1639    for r in rows.flatten() {
1640        out.insert(r.0, r.1);
1641    }
1642    Ok(out)
1643}
1644
1645/// Unread count for a SINGLE chat, same semantics as [`unread_counts`]. The RAM cache calls this to
1646/// reconcile one chat (open / delete / retreat) without recomputing every chat's count.
1647pub async fn unread_count_for_chat(chat_identifier: &str) -> Result<u32, String> {
1648    let conn = super::get_db_connection_guard_static()?;
1649    let count: i64 = conn
1650        .query_row(
1651            "SELECT COUNT(*) FROM events e JOIN chats c ON e.chat_id = c.id \
1652             WHERE c.chat_identifier = ?4 AND e.kind IN (?1, ?2, ?3) AND e.mine = 0 \
1653               AND e.created_at > COALESCE(( \
1654                     SELECT MAX(e2.created_at) FROM events e2 \
1655                     WHERE e2.chat_id = c.id \
1656                       AND ((e2.mine = 1 AND e2.kind IN (?1, ?2, ?3)) OR e2.id = c.last_read)), 0)",
1657            rusqlite::params![
1658                event_kind::CHAT_MESSAGE as i32,
1659                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1660                event_kind::FILE_ATTACHMENT as i32,
1661                chat_identifier
1662            ],
1663            |row| row.get(0),
1664        )
1665        .map_err(|e| format!("query unread_count_for_chat: {e}"))?;
1666    Ok(count as u32)
1667}
1668
1669/// What [`compute_unread_anchor`] decided a chat's read marker should become to surface its newest
1670/// contact message as unread. Computed from the full DB history (RAM may hold only a preview
1671/// message for an unopened community).
1672#[derive(Debug, PartialEq)]
1673pub enum UnreadMark {
1674    /// Nothing to surface: no contact message, or a strictly-newer own message (we spoke last).
1675    NoOp,
1676    /// Reset to the never-read anchor: the target is the chat's earliest message.
1677    Clear,
1678    /// Retreat `last_read` to this event id (the newest message in a strictly earlier second).
1679    Anchor(String),
1680}
1681
1682/// Decide how to mark `chat_identifier` unread. Anchors on the newest message strictly before the
1683/// newest contact message's second — the count query compares whole seconds with a strict `>`, so a
1684/// same-second anchor would leave the target on the boundary and it would read as caught-up.
1685pub async fn compute_unread_anchor(chat_identifier: &str) -> Result<UnreadMark, String> {
1686    let conn = super::get_db_connection_guard_static()?;
1687    let (k0, k1, k2) = (
1688        event_kind::CHAT_MESSAGE as i32,
1689        event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1690        event_kind::FILE_ATTACHMENT as i32,
1691    );
1692    // Newest non-mine message second (the target) and newest overall (to detect we spoke last).
1693    let (target_ts, newest_ts): (Option<i64>, Option<i64>) = conn
1694        .query_row(
1695            "SELECT MAX(CASE WHEN e.mine = 0 THEN e.created_at END), MAX(e.created_at) \
1696             FROM events e JOIN chats c ON e.chat_id = c.id \
1697             WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4)",
1698            rusqlite::params![chat_identifier, k0, k1, k2],
1699            |row| Ok((row.get(0)?, row.get(1)?)),
1700        )
1701        .map_err(|e| format!("unread anchor target: {e}"))?;
1702
1703    let target_ts = match target_ts {
1704        Some(t) => t,
1705        None => return Ok(UnreadMark::NoOp), // no contact message to surface
1706    };
1707    if newest_ts.map_or(false, |n| n > target_ts) {
1708        return Ok(UnreadMark::NoOp); // a strictly-newer own message → we spoke last
1709    }
1710
1711    let anchor_id: Option<String> = conn
1712        .query_row(
1713            "SELECT e.id FROM events e JOIN chats c ON e.chat_id = c.id \
1714             WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4) AND e.created_at < ?5 \
1715             ORDER BY e.created_at DESC LIMIT 1",
1716            rusqlite::params![chat_identifier, k0, k1, k2, target_ts],
1717            |row| row.get(0),
1718        )
1719        .optional()
1720        .map_err(|e| format!("unread anchor prev: {e}"))?;
1721
1722    Ok(match anchor_id {
1723        Some(id) => UnreadMark::Anchor(id),
1724        None => UnreadMark::Clear,
1725    })
1726}
1727
1728/// Drain a sync loop's pending message batch into one transaction. The shared flush for the
1729/// segment-flush pattern: bulk loops COLLECT message saves and call this at delete barriers +
1730/// loop end (a batched save committing AFTER a delete it originally preceded would resurrect
1731/// the deleted row — flushing first preserves wire order). Session-guarded HERE so every bulk
1732/// path gets the same swap-safety: on a stale session the batch is DROPPED, never written
1733/// into the next account's DB (the caller's loop is about to bail anyway).
1734pub async fn flush_message_batch(
1735    chat_id: &str,
1736    pending: &mut Vec<&Message>,
1737    session: &crate::state::SessionGuard,
1738) {
1739    if pending.is_empty() {
1740        return;
1741    }
1742    if !session.is_valid() {
1743        pending.clear();
1744        return;
1745    }
1746    if let Err(e) = save_messages_batch(chat_id, pending, Some(session)).await {
1747        crate::log_warn!("[DB] batch flush failed for {}: {}", chat_id, e);
1748    }
1749    pending.clear();
1750}
1751
1752/// Batch save messages for a chat — one transaction for the whole slice.
1753pub async fn save_chat_messages(chat_id: &str, messages: &[Message]) -> Result<(), String> {
1754    if messages.is_empty() {
1755        return Ok(());
1756    }
1757    let refs: Vec<&Message> = messages.iter().collect();
1758    save_messages_batch(chat_id, &refs, None).await.map(|_| ())
1759}
1760
1761#[cfg(test)]
1762mod tests {
1763    use super::*;
1764    use crate::stored_event::SystemEventType;
1765
1766    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(71000);
1767
1768    fn make_test_npub(n: u32) -> String {
1769        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1770        let mut payload = vec![b'q'; 58];
1771        let mut x = n as u64;
1772        let mut i = 58;
1773        while x > 0 && i > 0 {
1774            i -= 1;
1775            payload[i] = BECH32[(x as usize) % 32];
1776            x /= 32;
1777        }
1778        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1779    }
1780
1781    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1782        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1783        crate::db::close_database();
1784        // Each test rebinds to a fresh per-account DB; the row-id caches are per-account, so a stale
1785        // entry (e.g. a shared author npub) would point into the prior test's DB and FK-fail the insert.
1786        crate::db::clear_id_caches();
1787        let tmp = tempfile::tempdir().unwrap();
1788        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1789        let account = make_test_npub(n);
1790        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1791        crate::db::set_app_data_dir(tmp.path().to_path_buf());
1792        crate::db::set_current_account(account.clone()).unwrap();
1793        crate::db::init_database(&account).unwrap();
1794        (tmp, guard)
1795    }
1796
1797    fn now_secs() -> u64 {
1798        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
1799    }
1800
1801    /// A page of synced replies must come out of the batch resolver carrying their quotes.
1802    /// The sync/promote paths emit straight to the UI, and the renderer falls back to the
1803    /// in-memory parent only when the parent is inside the rendered window — so a reply to
1804    /// an OLDER message renders with no quote at all unless the backend resolved it here.
1805    /// Reopening the chat re-reads through `get_message_views` (which resolves), which is
1806    /// why the bug looked like "replies lose their context until you reopen".
1807    #[tokio::test]
1808    async fn batch_resolver_fills_quotes_for_a_synced_page() {
1809        let (_tmp, _guard) = init_test_db();
1810        let chat = "channel_reply_batch";
1811        let author = make_test_npub(90_001);
1812
1813        // An older, already-persisted parent (the case with no in-memory fallback).
1814        let mut parent = Message::default();
1815        parent.id = "parent_evt".to_string();
1816        parent.content = "the original message".to_string();
1817        parent.npub = Some(author.clone());
1818        parent.at = now_secs() - 5_000;
1819        save_message(chat, &parent).await.unwrap();
1820
1821        // The freshly-synced page: a reply to it, plus an unrelated message.
1822        let mut reply = Message::default();
1823        reply.id = "reply_evt".to_string();
1824        reply.content = "replying now".to_string();
1825        reply.replied_to = "parent_evt".to_string();
1826        reply.at = now_secs();
1827        let mut plain = Message::default();
1828        plain.id = "plain_evt".to_string();
1829        plain.content = "unrelated".to_string();
1830        plain.at = now_secs();
1831        // A reply whose parent this device has never seen must stay empty, not fabricate a quote.
1832        let mut orphan = Message::default();
1833        orphan.id = "orphan_evt".to_string();
1834        orphan.replied_to = "never_seen_evt".to_string();
1835        orphan.at = now_secs();
1836
1837        populate_reply_contexts(vec![&mut reply, &mut plain, &mut orphan]).await.unwrap();
1838
1839        assert_eq!(
1840            reply.replied_to_content.as_deref(),
1841            Some("the original message"),
1842            "the synced reply carries its parent's content"
1843        );
1844        assert_eq!(reply.replied_to_npub.as_deref(), Some(author.as_str()), "and its parent's author");
1845        assert!(plain.replied_to_content.is_none(), "a non-reply is untouched");
1846        assert!(orphan.replied_to_content.is_none(), "an unresolvable parent leaves the quote empty");
1847    }
1848
1849    /// An empty page (or one with no replies at all) must not query or error — the sync path
1850    /// calls this for every page, most of which carry no replies.
1851    #[tokio::test]
1852    async fn batch_resolver_is_a_noop_without_replies() {
1853        let (_tmp, _guard) = init_test_db();
1854        populate_reply_contexts(vec![]).await.unwrap();
1855        let mut plain = Message::default();
1856        plain.id = "solo".to_string();
1857        populate_reply_contexts(vec![&mut plain]).await.unwrap();
1858        assert!(plain.replied_to_content.is_none());
1859    }
1860
1861    // C-H2: a presence (join/leave) persisted from HISTORY must keep its authenticated timestamp so it
1862    // sorts where it happened, not at ingest-time "now"; a future-dated one is clamped so it can't jump
1863    // ahead of real activity.
1864    #[tokio::test]
1865    async fn system_event_stamps_authenticated_time_clamped_to_now() {
1866        let (_tmp, _guard) = init_test_db();
1867        let chat = "channel_ch2_timestamp";
1868        let before = now_secs();
1869        let past = before - 100_000;
1870
1871        save_system_event_at("ev_past", chat, SystemEventType::MemberJoined, "npubX", None, past, None, None).await.unwrap();
1872        save_system_event_at("ev_future", chat, SystemEventType::MemberJoined, "npubX", None, before + 100_000, None, None).await.unwrap();
1873        let after = now_secs();
1874
1875        let evs = get_system_events_for_chat(chat).unwrap();
1876        let past_ev = evs.iter().find(|e| e.id == "ev_past").expect("past event saved");
1877        assert_eq!(past_ev.created_at, past, "historical join keeps its real (authenticated) timestamp");
1878
1879        let fut_ev = evs.iter().find(|e| e.id == "ev_future").expect("future event saved");
1880        assert!(fut_ev.created_at >= before && fut_ev.created_at <= after,
1881            "future-dated event clamped to local now ({} not in {}..={})", fut_ev.created_at, before, after);
1882    }
1883
1884    // Delete-affordance resolution must work on paged-out rows: the events table is the
1885    // fallback source for (chat, mine, author) when a message isn't STATE-resident.
1886    #[tokio::test]
1887    async fn event_delete_context_resolves_from_db() {
1888        let (_tmp, _guard) = init_test_db();
1889        let chat = "npub1contactdc";
1890
1891        let mine_msg = Message { id: "dc_mine".into(), content: "x".into(), at: 1_000, mine: true, ..Default::default() };
1892        let theirs = Message {
1893            id: "dc_theirs".into(), content: "y".into(), at: 2_000, mine: false,
1894            npub: Some("npub1sender".to_string()),
1895            ..Default::default()
1896        };
1897        save_message(chat, &mine_msg).await.unwrap();
1898        save_message(chat, &theirs).await.unwrap();
1899
1900        let (chat_id, mine, _author) = event_delete_context("dc_mine").unwrap().expect("own row resolves");
1901        assert_eq!(chat_id, chat);
1902        assert!(mine);
1903
1904        let (chat_id, mine, author) = event_delete_context("dc_theirs").unwrap().expect("contact row resolves");
1905        assert_eq!(chat_id, chat);
1906        assert!(!mine);
1907        assert_eq!(author.as_deref(), Some("npub1sender"));
1908
1909        assert!(event_delete_context("dc_absent").unwrap().is_none(), "unknown id is None, not an error");
1910    }
1911
1912    // The reported bug: unread must accumulate across a restart (when only the last message per
1913    // chat is in RAM). The DB cutoff count must mirror the in-memory walk-back exactly.
1914    #[tokio::test]
1915    async fn unread_counts_match_walk_back_semantics() {
1916        let (_tmp, _guard) = init_test_db();
1917        let chat = "npub1contactdm";
1918        // `at` is ms (created_at = at/1000); use distinct seconds.
1919        let mk = |id: &str, secs: u64, mine: bool| Message {
1920            id: id.into(), content: "x".into(), at: secs * 1000, mine,
1921            npub: (!mine).then(|| "npub1sender".to_string()),
1922            ..Default::default()
1923        };
1924        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
1925
1926        // 6 contact messages, never opened/read, no own reply → all 6 unread.
1927        for i in 0..6u64 {
1928            save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap();
1929        }
1930        assert_eq!(unread().await, 6, "never-read backlog counts all 6");
1931
1932        // 2 more arrive → 8, NOT replaced by 2 (the exact reported symptom).
1933        save_message(chat, &mk("m6", 2000, false)).await.unwrap();
1934        save_message(chat, &mk("m7", 2001, false)).await.unwrap();
1935        assert_eq!(unread().await, 8, "6 backlog + 2 new = 8");
1936
1937        // Our own reply clears it (walk-back stops at the newest mine).
1938        save_message(chat, &mk("mine", 2002, true)).await.unwrap();
1939        assert_eq!(unread().await, 0, "own message = read up to here");
1940
1941        // A contact message after our send is unread again.
1942        save_message(chat, &mk("m8", 2003, false)).await.unwrap();
1943        assert_eq!(unread().await, 1, "one new after our send");
1944
1945        // last_read marker advances the cutoff just like an own message.
1946        {
1947            let conn = crate::db::get_write_connection_guard_static().unwrap();
1948            conn.execute(
1949                "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
1950                rusqlite::params!["m8", chat],
1951            ).unwrap();
1952        }
1953        assert_eq!(unread().await, 0, "last_read=m8 clears all");
1954        save_message(chat, &mk("m9", 2004, false)).await.unwrap();
1955        assert_eq!(unread().await, 1, "one arrival after last_read");
1956    }
1957
1958    // The single-chat reconcile query must agree with the full map for every state the cache
1959    // reconciles from (never-read, own-reply cutoff, last_read marker).
1960    #[tokio::test]
1961    async fn unread_count_for_chat_matches_the_map() {
1962        let (_tmp, _guard) = init_test_db();
1963        let chat = "npub1reconcile";
1964        let mk = |id: &str, secs: u64, mine: bool| Message {
1965            id: id.into(), content: "x".into(), at: secs * 1000, mine,
1966            npub: (!mine).then(|| "npub1sender".to_string()),
1967            ..Default::default()
1968        };
1969        let agree = || async {
1970            let map = unread_counts().await.unwrap().get(chat).copied().unwrap_or(0);
1971            let one = unread_count_for_chat(chat).await.unwrap();
1972            assert_eq!(map, one, "single-chat query diverged from the map");
1973            one
1974        };
1975
1976        for i in 0..4u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap(); }
1977        assert_eq!(agree().await, 4);
1978        save_message(chat, &mk("mine", 1010, true)).await.unwrap();
1979        assert_eq!(agree().await, 0);
1980        save_message(chat, &mk("after", 1011, false)).await.unwrap();
1981        assert_eq!(agree().await, 1);
1982        {
1983            let conn = crate::db::get_write_connection_guard_static().unwrap();
1984            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
1985                rusqlite::params!["after", chat]).unwrap();
1986        }
1987        assert_eq!(agree().await, 0);
1988        // A chat with nothing at all reconciles to 0 (no row in the map).
1989        assert_eq!(unread_count_for_chat("npub1nonexistent").await.unwrap(), 0);
1990    }
1991
1992    #[tokio::test]
1993    async fn attachments_table_round_trip_dedup_and_cascade() {
1994        let (_tmp, _guard) = init_test_db();
1995        // downloaded:false mirrors a freshly-received attachment (Attachment::default is downloaded:true).
1996        let att = |id: &str, name: &str| Attachment {
1997            id: id.into(), url: format!("https://blossom/{id}"), name: name.into(),
1998            extension: "png".into(), size: 42, downloaded: false, ..Default::default()
1999        };
2000        let msg = |mid: &str, secs: u64, atts: Vec<Attachment>| Message {
2001            id: mid.into(), content: String::new(), at: secs * 1000, mine: false,
2002            npub: Some("npub1sender".into()), attachments: atts, ..Default::default()
2003        };
2004
2005        // Save a message with two attachments → both rows, order preserved by att_index.
2006        save_message("npub1att", &msg("m1", 1000, vec![att("hashA", "a.png"), att("hashB", "b.png")])).await.unwrap();
2007        let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2008        assert_eq!(got.len(), 2);
2009        assert_eq!((got[0].id.as_str(), got[1].id.as_str()), ("hashA", "hashB"), "att_index order");
2010        assert_eq!(got[0].name, "a.png");
2011        assert_eq!(got[0].size, 42);
2012        assert!(!got[0].downloaded);
2013
2014        // Single-row download flip (no read-modify-write of a blob).
2015        crate::db::attachments::set_attachment_downloaded("m1", "hashA", true, "/tmp/a.png").unwrap();
2016        let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2017        assert!(got[0].downloaded && got[0].path == "/tmp/a.png");
2018        assert!(!got[1].downloaded, "sibling attachment untouched");
2019
2020        // Dedup: a second message shares hashA → backfill-by-hash marks it (indexed, no LIKE scan).
2021        save_message("npub1att", &msg("m2", 1001, vec![att("hashA", "a-again.png")])).await.unwrap();
2022        let affected = crate::db::attachments::backfill_downloaded_by_hash("hashA", "/tmp/a.png", "m1").unwrap();
2023        assert_eq!(affected, vec!["m2".to_string()]);
2024        assert!(crate::db::attachments::get_attachments_for_event("m2").unwrap()[0].downloaded);
2025
2026        // The write funnel dual-populates the legacy tag, so get_message_views (still tag-backed
2027        // during shadow-populate) composes the attachments onto the Message.
2028        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1att").unwrap();
2029        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2030        let m1 = views.iter().find(|m| m.id == "m1").unwrap();
2031        assert_eq!(m1.attachments.len(), 2);
2032
2033        // Cascade: deleting the event removes its attachment rows.
2034        delete_event("m1").await.unwrap();
2035        assert!(crate::db::attachments::get_attachments_for_event("m1").unwrap().is_empty(), "ON DELETE CASCADE");
2036    }
2037
2038    // The download persist path: a re-save must never DOWNGRADE download state (relay re-delivery),
2039    // but a re-save carrying a completed download (+ the nonce→content-hash id rewrite) must persist.
2040    #[tokio::test]
2041    async fn attachment_download_state_is_monotonic_across_resaves() {
2042        let (_tmp, _guard) = init_test_db();
2043        let att = |id: &str, downloaded: bool, path: &str| Attachment {
2044            id: id.into(), url: "u".into(), name: "f.png".into(), extension: "png".into(),
2045            size: 1, downloaded, path: path.into(), ..Default::default()
2046        };
2047        let msg = |atts: Vec<Attachment>| Message {
2048            id: "dl1".into(), content: String::new(), at: 1_000_000, mine: false,
2049            npub: Some("npub1s".into()), attachments: atts, ..Default::default()
2050        };
2051
2052        // Receive (not downloaded), then the user downloads → single-row flip.
2053        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2054        crate::db::attachments::set_attachment_downloaded("dl1", "nonceid", true, "/tmp/f.png").unwrap();
2055
2056        // Relay re-delivery (downloaded=false) must NOT reset the download.
2057        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2058        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2059        assert!(got[0].downloaded && got[0].path == "/tmp/f.png", "re-delivery preserves the download");
2060
2061        // Post-download re-save: id rewritten nonce→content-hash, downloaded=true persists in one pass.
2062        save_message("npub1dl", &msg(vec![att("contenthash", true, "/tmp/f.png")])).await.unwrap();
2063        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2064        assert_eq!(got[0].id, "contenthash", "hash rewritten nonce→content");
2065        assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2066
2067        // A re-delivery AFTER the rewrite must keep the content-hash key (not revert to the nonce),
2068        // so the hash-keyed download/backfill/clear helpers still resolve the row.
2069        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2070        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2071        assert_eq!(got[0].id, "contenthash", "content-hash key survives a later re-delivery");
2072        assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2073    }
2074
2075    // An un-backfilled pre-migration event (attachments only in the legacy tag, no table row) still
2076    // renders via the read fallback.
2077    #[tokio::test]
2078    async fn attachments_fall_back_to_legacy_tag_when_table_empty() {
2079        let (_tmp, _guard) = init_test_db();
2080        let a = Attachment {
2081            id: "tagonly".into(), url: "u".into(), name: "old.png".into(), extension: "png".into(),
2082            size: 7, downloaded: false, ..Default::default()
2083        };
2084        save_message("npub1old", &Message {
2085            id: "old1".into(), content: String::new(), at: 2_000_000, mine: false,
2086            npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2087        }).await.unwrap();
2088
2089        // Simulate the pre-migration shape: drop the table rows, write the legacy tag onto the event.
2090        {
2091            let conn = crate::db::get_write_connection_guard_static().unwrap();
2092            conn.execute("DELETE FROM attachments WHERE event_id='old1'", []).unwrap();
2093            let inner = serde_json::to_string(&vec![a]).unwrap();
2094            let tags = serde_json::to_string(&vec![vec!["attachments".to_string(), inner]]).unwrap();
2095            conn.execute("UPDATE events SET tags=?1 WHERE id='old1'", rusqlite::params![tags]).unwrap();
2096        }
2097        assert!(crate::db::attachments::get_attachments_for_event("old1").unwrap().is_empty(), "table empty");
2098
2099        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1old").unwrap();
2100        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2101        let old = views.iter().find(|m| m.id == "old1").unwrap();
2102        assert_eq!(old.attachments.len(), 1, "attachment served from the legacy-tag fallback");
2103        assert_eq!(old.attachments[0].name, "old.png");
2104    }
2105
2106    // Migration 75's strip logic: remove the legacy attachments tag ONLY from events that are
2107    // provably backfilled (have a table row); un-backfilled events keep their tag as the fallback.
2108    #[tokio::test]
2109    async fn attachment_tag_strip_is_gated_on_backfill() {
2110        let (_tmp, _guard) = init_test_db();
2111        let a = Attachment { id: "h1".into(), extension: "png".into(), name: "f.png".into(), downloaded: false, ..Default::default() };
2112        let mk = |id: &str, secs: u64| Message {
2113            id: id.into(), content: String::new(), at: secs * 1000, mine: false,
2114            npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2115        };
2116        save_message("npub1s", &mk("bf", 1000)).await.unwrap();
2117        save_message("npub1s", &mk("unbf", 2000)).await.unwrap();
2118
2119        {
2120            let conn = crate::db::get_write_connection_guard_static().unwrap();
2121            let inner = serde_json::to_string(&vec![a.clone()]).unwrap();
2122            let with_tag = |ms: &str| serde_json::to_string(&vec![
2123                vec!["ms".to_string(), ms.to_string()],
2124                vec!["attachments".to_string(), inner.clone()],
2125            ]).unwrap();
2126            // Both events carry a legacy tag; only `unbf` loses its table rows (un-backfilled).
2127            conn.execute("UPDATE events SET tags=?1 WHERE id='bf'", rusqlite::params![with_tag("5")]).unwrap();
2128            conn.execute("UPDATE events SET tags=?1 WHERE id='unbf'", rusqlite::params![with_tag("6")]).unwrap();
2129            conn.execute("DELETE FROM attachments WHERE event_id='unbf'", []).unwrap();
2130
2131            // Replicate migration 75's gated strip.
2132            let events: Vec<(String, String)> = {
2133                let mut stmt = conn.prepare("SELECT id, tags FROM events WHERE tags LIKE '%attachments%' AND id IN (SELECT DISTINCT event_id FROM attachments)").unwrap();
2134                let m = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).unwrap();
2135                m.flatten().collect()
2136            };
2137            for (id, tj) in events {
2138                let mut tags: Vec<Vec<String>> = serde_json::from_str(&tj).unwrap();
2139                tags.retain(|t| t.first().map(|s| s.as_str()) != Some("attachments"));
2140                conn.execute("UPDATE events SET tags=?1 WHERE id=?2",
2141                    rusqlite::params![serde_json::to_string(&tags).unwrap(), id]).unwrap();
2142            }
2143
2144            let bf: String = conn.query_row("SELECT tags FROM events WHERE id='bf'", [], |r| r.get(0)).unwrap();
2145            assert!(!bf.contains("attachments"), "backfilled event's attachments tag stripped");
2146            assert!(bf.contains("\"ms\""), "sibling ms tag survives the strip");
2147            let unbf: String = conn.query_row("SELECT tags FROM events WHERE id='unbf'", [], |r| r.get(0)).unwrap();
2148            assert!(unbf.contains("attachments"), "un-backfilled event keeps its tag (no table row)");
2149        }
2150
2151        // The un-backfilled event still renders via the read fallback after the strip.
2152        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1s").unwrap();
2153        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2154        assert_eq!(views.iter().find(|m| m.id == "unbf").unwrap().attachments.len(), 1, "fallback still renders unbf");
2155    }
2156
2157    // Mark-as-unread anchors from the FULL DB history (a community row often holds only a preview
2158    // message in RAM). The anchor lands strictly before the target's second so the count query's
2159    // strict `>` still counts the newest contact message. Covers the community repro + edge cases.
2160    #[tokio::test]
2161    async fn compute_unread_anchor_covers_the_cases() {
2162        let (_tmp, _guard) = init_test_db();
2163        let mk = |id: &str, secs: u64, mine: bool| Message {
2164            id: id.into(), content: "x".into(), at: secs * 1000, mine,
2165            npub: (!mine).then(|| "npub1sender".to_string()),
2166            ..Default::default()
2167        };
2168        let unread = |chat: &'static str| async move {
2169            unread_counts().await.unwrap().get(chat).copied().unwrap_or(0)
2170        };
2171        let set_lr = |chat: &str, lr: &str| {
2172            let conn = crate::db::get_write_connection_guard_static().unwrap();
2173            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2174                rusqlite::params![lr, chat]).unwrap();
2175        };
2176
2177        // (A) The community repro: we spoke long ago, they kept talking. Anchor = second-newest
2178        // contact message → exactly one unread, whatever the RAM cache held.
2179        let a = "npub1anchorA";
2180        save_message(a, &mk("a_mine", 1000, true)).await.unwrap();
2181        for i in 0..8u64 { save_message(a, &mk(&format!("a{i}"), 2000 + i, false)).await.unwrap(); }
2182        assert_eq!(compute_unread_anchor(a).await.unwrap(), UnreadMark::Anchor("a6".into()));
2183        set_lr(a, "a6");
2184        assert_eq!(unread(a).await, 1, "A: newest contact message is the sole unread");
2185
2186        // (B) We spoke last → NoOp (no phantom badge, no snap-back jiggle).
2187        let b = "npub1anchorB";
2188        save_message(b, &mk("b0", 2000, false)).await.unwrap();
2189        save_message(b, &mk("b_mine", 2001, true)).await.unwrap();
2190        assert_eq!(compute_unread_anchor(b).await.unwrap(), UnreadMark::NoOp);
2191
2192        // (C) Same-second tail: the two newest share a second. The anchor must skip to a strictly
2193        // earlier second, so both same-second messages surface instead of snapping back to read.
2194        let c = "npub1anchorC";
2195        save_message(c, &mk("c0", 3000, false)).await.unwrap();
2196        save_message(c, &mk("c1", 3005, false)).await.unwrap();
2197        save_message(c, &mk("c2", 3005, false)).await.unwrap();
2198        assert_eq!(compute_unread_anchor(c).await.unwrap(), UnreadMark::Anchor("c0".into()));
2199        set_lr(c, "c0");
2200        assert_eq!(unread(c).await, 2, "C: same-second tail both count");
2201
2202        // (D) The newest contact message is the chat's first → Clear (never-read) → it still counts.
2203        let d = "npub1anchorD";
2204        save_message(d, &mk("d0", 4000, false)).await.unwrap();
2205        assert_eq!(compute_unread_anchor(d).await.unwrap(), UnreadMark::Clear);
2206        set_lr(d, "");
2207        assert_eq!(unread(d).await, 1, "D: lone contact message surfaces");
2208
2209        // (E) No contact message at all (only our own) → NoOp.
2210        let e = "npub1anchorE";
2211        save_message(e, &mk("e_mine", 5000, true)).await.unwrap();
2212        assert_eq!(compute_unread_anchor(e).await.unwrap(), UnreadMark::NoOp);
2213    }
2214
2215    // Regression: a "read to here" marker that lands on a system event (kind 30078, not a counted
2216    // kind, e.g. the windowed jump-reveal path marking off the raw tail) must still clear unread.
2217    // The anchor keys off the marker row's time whatever its kind, so it can't wedge at 99+.
2218    /// A blank conversation id is refused, never minted into a chat row. Left to
2219    /// `get_or_create_chat_id` it created a chat keyed by "" — and because the
2220    /// identifier is UNIQUE, presence from EVERY channel-less community collapsed
2221    /// into that one unopenable row (82 events across weeks, on a live account).
2222    #[tokio::test]
2223    async fn a_system_event_with_a_blank_conversation_id_is_refused() {
2224        let (_tmp, _guard) = init_test_db();
2225        let before = chat_row_count();
2226        for blank in ["", "   "] {
2227            assert!(
2228                save_system_event_at("ev", blank, SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2229                    .await
2230                    .is_err(),
2231                "a blank conversation id must be refused, not minted"
2232            );
2233        }
2234        assert_eq!(chat_row_count(), before, "and no chat row is created");
2235        // A real id still works.
2236        assert!(
2237            save_system_event_at("ev2", "real-chat", SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2238                .await
2239                .unwrap()
2240        );
2241    }
2242
2243    fn chat_row_count() -> i64 {
2244        let conn = crate::db::get_db_connection_guard_static().unwrap();
2245        conn.query_row("SELECT count(*) FROM chats", [], |r| r.get(0)).unwrap()
2246    }
2247
2248    #[tokio::test]
2249    async fn unread_clears_when_last_read_is_a_system_event() {
2250        let (_tmp, _guard) = init_test_db();
2251        let chat = "npub1sysevtdm";
2252        let mk = |id: &str, secs: u64| Message {
2253            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2254            npub: Some("npub1sender".to_string()), ..Default::default()
2255        };
2256        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2257
2258        // 5 contact messages, never read → all unread. No own message, so the ONLY viable anchor
2259        // is last_read (this is the case that used to stick at a permanent count).
2260        for i in 0..5u64 {
2261            save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap();
2262        }
2263        assert_eq!(unread().await, 5, "never-read backlog");
2264
2265        // A system event is the newest row (a join notification, after every contact message).
2266        save_system_event_at("sysev", chat, SystemEventType::MemberJoined, "npubX", None, 2000, None, None).await.unwrap();
2267
2268        // last_read pinned to that system event (kind 30078) — the reported bad state.
2269        {
2270            let conn = crate::db::get_write_connection_guard_static().unwrap();
2271            conn.execute(
2272                "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2273                rusqlite::params!["sysev", chat],
2274            ).unwrap();
2275        }
2276        assert_eq!(unread().await, 0, "read marker on a system event still clears the badge");
2277    }
2278
2279    // Deleting a message adjusts unread correctly: removing an UNREAD message drops the count by
2280    // one, removing the read MARKER retreats it to the prior message (never collapses to 99+), and
2281    // removing the last read message clears the marker without over-counting.
2282    #[tokio::test]
2283    async fn deleting_a_message_adjusts_unread_without_wedging() {
2284        let (_tmp, _guard) = init_test_db();
2285        let chat = "npub1delunread";
2286        let mk = |id: &str, secs: u64| Message {
2287            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2288            npub: Some("npub1sender".to_string()), ..Default::default()
2289        };
2290        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2291        let set_marker = |id: &str| {
2292            let conn = crate::db::get_write_connection_guard_static().unwrap();
2293            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2294                rusqlite::params![id, chat]).unwrap();
2295        };
2296        let marker = || -> String {
2297            let conn = crate::db::get_db_connection_guard_static().unwrap();
2298            conn.query_row("SELECT last_read FROM chats WHERE chat_identifier = ?1",
2299                rusqlite::params![chat], |r| r.get::<_, String>(0)).unwrap()
2300        };
2301
2302        // m0..m5, read up to m1 → m2,m3,m4,m5 unread.
2303        for i in 0..6u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap(); }
2304        set_marker("m1");
2305        assert_eq!(unread().await, 4, "m2..m5 unread");
2306
2307        // Delete an UNREAD mid-block message → badge drops by one, marker untouched.
2308        delete_event("m3").await.unwrap();
2309        assert_eq!(unread().await, 3, "one unread deleted → badge minus one");
2310        assert_eq!(marker(), "m1", "deleting an unread message leaves the marker alone");
2311
2312        // Delete the read MARKER → retreats to the prior surviving message (m0), unread unchanged.
2313        delete_event("m1").await.unwrap();
2314        assert_eq!(marker(), "m0", "marker retreats to the newest survivor before it");
2315        assert_eq!(unread().await, 3, "retreat keeps the count, no collapse to 99+");
2316
2317        // Delete the last surviving read message → marker clears, still exactly the unread block.
2318        delete_event("m0").await.unwrap();
2319        assert_eq!(marker(), "", "no earlier survivor → marker clears");
2320        assert_eq!(unread().await, 3, "cleared marker counts only the true unread survivors");
2321    }
2322
2323    // Edits are event-sourced for BOTH transports: a MESSAGE_EDIT event folds into the target's
2324    // history on reload (latest content + revisions + the edit's own emoji). Community used to
2325    // overwrite the row and lose all of this — this locks in the unified fold.
2326    #[tokio::test]
2327    async fn edit_event_folds_into_history_on_reload() {
2328        let (_tmp, _guard) = init_test_db();
2329        let chat = "channel_edit_fold";
2330        save_message(chat, &Message {
2331            id: "orig1".into(), content: "original".into(), at: 5_000_000,
2332            npub: Some("npub1author".into()), ..Default::default()
2333        }).await.unwrap();
2334
2335        let cid = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2336        let emoji = vec![crate::types::EmojiTag { shortcode: "wave".into(), url: "u/wave".into() }];
2337        save_edit_event("edit1", "orig1", "edited :wave:", &emoji, cid, None, "npub1author").await.unwrap();
2338
2339        let m = get_message_views(cid, 50, 0).await.unwrap()
2340            .into_iter().find(|m| m.id == "orig1").expect("message reloaded");
2341        assert!(m.edited, "folded edit sets the edited flag");
2342        let h = m.edit_history.as_ref().expect("history reconstructed from the edit event");
2343        assert_eq!(h.len(), 2, "original + one edit");
2344        assert_eq!(h[0].content, "original");
2345        assert_eq!(h[1].content, "edited :wave:");
2346        assert_eq!(m.content, "edited :wave:", "latest revision is the displayed content");
2347        assert_eq!(m.emoji_tags.len(), 1, "the edit's own emoji folds onto the message");
2348        assert_eq!(m.emoji_tags[0].shortcode, "wave");
2349    }
2350
2351    // The bulk-sync persist path: one transaction must land events + attachments + reactions
2352    // with the same shape save_message produces, and slice order must set rowid order (the
2353    // same-timestamp pagination tiebreak).
2354    #[tokio::test]
2355    async fn batched_save_matches_single_save_shape() {
2356        let (_tmp, _guard) = init_test_db();
2357        let chat = "channel_batch1";
2358        let att = crate::types::Attachment {
2359            id: "atthash1".into(), extension: "png".into(), name: "a.png".into(),
2360            url: "https://x/att".into(), downloaded: false, ..Default::default()
2361        };
2362        let reaction = Reaction {
2363            id: "react_b1".into(), reference_id: "b1".into(),
2364            author_id: "npub1reactor".into(), emoji: "👍".into(), emoji_url: None,
2365        };
2366        // Same `at` second across the batch — rowid is the only orderer.
2367        let msgs: Vec<Message> = (0..5u64).map(|i| Message {
2368            id: format!("b{i}"), content: format!("c{i}"), at: 7_000_000,
2369            npub: Some("npub1sender".into()),
2370            attachments: if i == 2 { vec![att.clone()] } else { Vec::new() },
2371            reactions: if i == 1 { vec![reaction.clone()] } else { Vec::new() },
2372            ..Default::default()
2373        }).collect();
2374        let refs: Vec<&Message> = msgs.iter().collect();
2375
2376        let saved = save_messages_batch(chat, &refs, None).await.unwrap();
2377        assert_eq!(saved, 5, "every message written");
2378
2379        for i in 0..5u64 {
2380            assert!(event_exists(&format!("b{i}")).unwrap(), "b{i} row exists");
2381        }
2382        assert!(event_exists("react_b1").unwrap(), "reaction landed as its own kind-7 row");
2383        let atts = crate::db::attachments::get_attachments_for_event("b2").unwrap();
2384        assert_eq!(atts.len(), 1, "attachment row committed with its event");
2385        assert_eq!(atts[0].id, "atthash1");
2386
2387        // rowid order == slice order despite identical timestamps.
2388        let conn = crate::db::get_db_connection_guard_static().unwrap();
2389        let ids: Vec<String> = conn
2390            .prepare("SELECT id FROM events WHERE id IN ('b0','b1','b2','b3','b4') ORDER BY rowid")
2391            .unwrap()
2392            .query_map([], |r| r.get(0)).unwrap()
2393            .flatten().collect();
2394        assert_eq!(ids, vec!["b0", "b1", "b2", "b3", "b4"], "insert order preserves the rowid tiebreak");
2395    }
2396
2397    // A batched re-save must keep save_message's upsert semantics: wrapper_event_id is
2398    // COALESCE-preserved, and a duplicate reaction in the batch never doubles its row.
2399    #[tokio::test]
2400    async fn batched_resave_preserves_wrapper_and_dedups_reactions() {
2401        let (_tmp, _guard) = init_test_db();
2402        let chat = "channel_batch2";
2403        let reaction = Reaction {
2404            id: "react_rs".into(), reference_id: "rs1".into(),
2405            author_id: "npub1reactor".into(), emoji: "🔥".into(), emoji_url: None,
2406        };
2407        let mut msg = Message {
2408            id: "rs1".into(), content: "hello".into(), at: 8_000_000,
2409            npub: Some("npub1sender".into()),
2410            wrapper_event_id: Some("wrap_original".into()),
2411            reactions: vec![reaction],
2412            ..Default::default()
2413        };
2414        save_message(chat, &msg).await.unwrap();
2415
2416        // Re-delivery re-save without a wrapper id, reaction still attached.
2417        msg.wrapper_event_id = None;
2418        let saved = save_messages_batch(chat, &[&msg], None).await.unwrap();
2419        assert_eq!(saved, 1);
2420
2421        let conn = crate::db::get_db_connection_guard_static().unwrap();
2422        let wrapper: Option<String> = conn.query_row(
2423            "SELECT wrapper_event_id FROM events WHERE id = 'rs1'", [], |r| r.get(0),
2424        ).unwrap();
2425        assert_eq!(wrapper.as_deref(), Some("wrap_original"), "COALESCE keeps the known wrapper");
2426        let reaction_rows: i64 = conn.query_row(
2427            "SELECT COUNT(*) FROM events WHERE id = 'react_rs'", [], |r| r.get(0),
2428        ).unwrap();
2429        assert_eq!(reaction_rows, 1, "reaction row not duplicated by the re-save");
2430    }
2431
2432    // The DM stream's multi-chat flush: one call, one transaction, rows land under their own
2433    // chats with their gift-wrap ledger entries; a flush against a stale session drops the
2434    // buffer AND leaves the wrappers unledgered (that's what makes the drop recoverable).
2435    #[tokio::test]
2436    async fn batching_persist_flushes_multi_chat_and_drops_on_stale_session() {
2437        let (_tmp, _guard) = init_test_db();
2438        let handler = crate::event_handler::NoOpEventHandler;
2439        let batcher = crate::event_handler::BatchingPersist::new(&handler);
2440
2441        let mk = |id: &str, npub: &str| Message {
2442            id: id.into(), content: "x".into(), at: 9_000_000,
2443            npub: Some(npub.into()), ..Default::default()
2444        };
2445        // Mirror the commit path: buffering only ever happens AFTER the STATE add (the
2446        // flush drops anything not STATE-resident as deletion protection).
2447        let seed = |chat: &str, m: &Message| {
2448            let m = m.clone();
2449            let chat = chat.to_string();
2450            async move {
2451                let mut st = crate::state::STATE.lock().await;
2452                st.add_message_to_participant(&chat, &m);
2453            }
2454        };
2455        let wrap_a1 = ([0xA1u8; 32], 111u64);
2456        let wrap_b1 = ([0xB1u8; 32], 222u64);
2457        let a1 = mk("bp_a1", "npub1chata");
2458        let b1 = mk("bp_b1", "npub1chatb");
2459        let a2 = mk("bp_a2", "npub1chata");
2460        seed("npub1chata", &a1).await;
2461        seed("npub1chatb", &b1).await;
2462        seed("npub1chata", &a2).await;
2463
2464        use crate::event_handler::InboundEventHandler;
2465        assert!(batcher.buffer_persist("npub1chata", &a1, Some(wrap_a1)), "batcher owns the persist");
2466        assert!(batcher.buffer_persist("npub1chatb", &b1, Some(wrap_b1)));
2467        assert!(batcher.buffer_persist("npub1chata", &a2, None));
2468        assert_eq!(batcher.buffered(), 3);
2469
2470        let ledgered = |bytes: [u8; 32]| {
2471            let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
2472            crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
2473        };
2474        assert!(!ledgered(wrap_a1.0), "wrapper unledgered while its message sits buffered");
2475
2476        let session = crate::state::SessionGuard::capture();
2477        assert_eq!(batcher.flush(&session).await, 3, "all buffered messages written");
2478        assert_eq!(batcher.buffered(), 0);
2479        assert!(event_exists("bp_a1").unwrap() && event_exists("bp_b1").unwrap() && event_exists("bp_a2").unwrap());
2480        assert!(ledgered(wrap_a1.0) && ledgered(wrap_b1.0), "wrappers ledgered with the flush");
2481        let a = crate::db::id_cache::get_chat_id_by_identifier("npub1chata").unwrap();
2482        let b = crate::db::id_cache::get_chat_id_by_identifier("npub1chatb").unwrap();
2483        assert_ne!(a, b, "rows grouped under their own chats");
2484
2485        // Stale session: buffered messages are dropped, never written — and the wrapper
2486        // stays out of the negentropy fingerprint set, so the message re-delivers.
2487        let stale = mk("bp_stale", "npub1chata");
2488        seed("npub1chata", &stale).await;
2489        let wrap_stale = ([0x5Eu8; 32], 333u64);
2490        batcher.buffer_persist("npub1chata", &stale, Some(wrap_stale));
2491        crate::state::bump_session_generation();
2492        assert_eq!(batcher.flush(&session).await, 0, "stale flush writes nothing");
2493        assert!(!event_exists("bp_stale").unwrap(), "stale message never reached the DB");
2494        assert!(!ledgered(wrap_stale.0), "dropped message's wrapper NOT ledgered — negentropy will re-deliver it");
2495        assert_eq!(batcher.buffered(), 0, "stale buffer drained, not retried into the next account");
2496    }
2497
2498    // A deletion landing while its target sits buffered must not resurrect the message:
2499    // the same-task path purges via on_message_deleted, and the cross-task path (live
2500    // subscription, different handler) is caught by the flush's deletion-tombstone filter.
2501    // The filter keys on the POSITIVE tombstone, never STATE absence — an LRU-evicted (but
2502    // not deleted) message MUST still persist and ledger, or archive sync could silently
2503    // drop the exact history it exists to persist.
2504    #[tokio::test]
2505    async fn buffered_message_deleted_before_flush_never_persists() {
2506        let (_tmp, _guard) = init_test_db();
2507        let handler = crate::event_handler::NoOpEventHandler;
2508        let batcher = crate::event_handler::BatchingPersist::new(&handler);
2509        use crate::event_handler::InboundEventHandler;
2510
2511        let chat = "npub1delchat";
2512        let mk = |id: &str| Message {
2513            id: id.into(), content: "x".into(), at: 9_500_000,
2514            npub: Some(chat.into()), ..Default::default()
2515        };
2516        let ledgered = |bytes: [u8; 32]| {
2517            let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
2518            crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
2519        };
2520
2521        // Same-task deletion (sync stream): commit_deletion fires on_message_deleted on
2522        // the batching handler → the buffered entry purges immediately.
2523        let m1 = mk("del_sametask");
2524        {
2525            let mut st = crate::state::STATE.lock().await;
2526            st.add_message_to_participant(chat, &m1);
2527        }
2528        batcher.buffer_persist(chat, &m1, Some(([0xD1u8; 32], 444)));
2529        {
2530            let mut st = crate::state::STATE.lock().await;
2531            st.remove_message("del_sametask");
2532        }
2533        batcher.on_message_deleted(chat, "del_sametask");
2534        assert_eq!(batcher.buffered(), 0, "deletion purges the buffered target");
2535
2536        // Cross-task deletion (live subscription, plain handler): no purge call — the
2537        // deletion tombstone (recorded by commit_deletion) drops it at flush time.
2538        let m2 = mk("del_crosstask");
2539        {
2540            let mut st = crate::state::STATE.lock().await;
2541            st.add_message_to_participant(chat, &m2);
2542        }
2543        batcher.buffer_persist(chat, &m2, Some(([0xD2u8; 32], 555)));
2544        crate::state::note_message_deleted("del_crosstask");
2545
2546        // LRU eviction is NOT deletion: gone from STATE, no tombstone → must persist.
2547        let m3 = mk("evicted_ok");
2548        let wrap_evicted = ([0xE0u8; 32], 666u64);
2549        {
2550            let mut st = crate::state::STATE.lock().await;
2551            st.add_message_to_participant(chat, &m3);
2552            st.remove_message("evicted_ok");
2553        }
2554        batcher.buffer_persist(chat, &m3, Some(wrap_evicted));
2555
2556        let session = crate::state::SessionGuard::capture();
2557        assert_eq!(batcher.flush(&session).await, 1, "tombstoned target dropped, evicted message written");
2558        assert!(!event_exists("del_sametask").unwrap(), "purged message never persisted");
2559        assert!(!event_exists("del_crosstask").unwrap(), "tombstoned message never persisted");
2560        assert!(event_exists("evicted_ok").unwrap(), "evicted-but-not-deleted message persisted");
2561        assert!(ledgered(wrap_evicted.0), "evicted message's wrapper ledgered with it");
2562    }
2563}
2564
2565/// The stored context a pin proof needs to recover a message's wrap: its
2566/// `wrapper_event_id` and the rumor's stored tags (for the epoch binding).
2567pub fn get_event_wrap_context(event_id: &str) -> Result<Option<(Option<String>, Vec<Vec<String>>)>, String> {
2568    let conn = super::get_db_connection_guard_static()?;
2569    let row: Option<(Option<String>, String)> = conn
2570        .query_row(
2571            "SELECT wrapper_event_id, tags FROM events WHERE id = ?1",
2572            rusqlite::params![event_id],
2573            |r| Ok((r.get(0)?, r.get(1)?)),
2574        )
2575        .optional()
2576        .map_err(|e| format!("get wrap context: {e}"))?;
2577    Ok(row.map(|(wrap, tags_json)| {
2578        let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
2579        (wrap, tags)
2580    }))
2581}