//! Multi-conversation state backed by SQLite (SQLCipher-encrypted when a
//! password is provided).
//!
//! Each conversation (DM or group) has its own MLS group blob, keystore blob,
//! member list, and message history.

use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Context;
use argon2::{Algorithm, Argon2, Params, Version};
use rand::RngCore;
use rusqlite::{params, Connection, OptionalExtension};
use zeroize::Zeroizing;

// ── Types ────────────────────────────────────────────────────────────────────

/// 16-byte conversation identifier.
///   - DMs: the channel_id returned by `createChannel` (server-assigned UUID).
///   - Groups: SHA-256(group_name)[..16].
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ConversationId(pub [u8; 16]);

impl ConversationId {
    pub fn from_slice(s: &[u8]) -> Option<Self> {
        if s.len() == 16 {
            let mut buf = [0u8; 16];
            buf.copy_from_slice(s);
            Some(Self(buf))
        } else {
            None
        }
    }

    /// Derive a conversation ID from a group name via SHA-256 truncation.
    pub fn from_group_name(name: &str) -> Self {
        use sha2::{Sha256, Digest};
        let hash = Sha256::digest(name.as_bytes());
        let mut buf = [0u8; 16];
        buf.copy_from_slice(&hash[..16]);
        Self(buf)
    }

    pub fn hex(&self) -> String {
        hex::encode(self.0)
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConversationKind {
    /// 1:1 DM channel with a specific peer.
    Dm {
        peer_key: Vec<u8>,
        peer_username: Option<String>,
    },
    /// Named group with N members.
    Group { name: String },
}

#[derive(Clone, Debug)]
pub struct Conversation {
    pub id: ConversationId,
    pub kind: ConversationKind,
    pub display_name: String,
    /// Serialized MLS group (bincode).
    pub mls_group_blob: Option<Vec<u8>>,
    /// Serialized keystore (bincode HashMap).
    pub keystore_blob: Option<Vec<u8>>,
    /// Member identity keys (bincode Vec<Vec<u8>>).
    pub member_keys: Vec<Vec<u8>>,
    pub unread_count: u32,
    pub last_activity_ms: u64,
    pub created_at_ms: u64,
    /// Whether this conversation uses hybrid (X25519 + ML-KEM-768) MLS keys.
    pub is_hybrid: bool,
    /// Highest server-side delivery sequence number seen.
    pub last_seen_seq: u64,
}

#[derive(Clone, Debug)]
pub struct StoredMessage {
    pub conversation_id: ConversationId,
    pub message_id: Option<[u8; 16]>,
    pub sender_key: Vec<u8>,
    pub sender_name: Option<String>,
    pub body: String,
    pub msg_type: String,
    pub ref_msg_id: Option<[u8; 16]>,
    pub timestamp_ms: u64,
    pub is_outgoing: bool,
}

// ── Key derivation (Argon2id, matching state.rs parameters) ─────────────────

const ARGON2_M_COST: u32 = 19 * 1024;
const ARGON2_T_COST: u32 = 2;
const ARGON2_P_COST: u32 = 1;
const SALT_LEN: usize = 16;

/// Derive a 32-byte SQLCipher key from the user password and a random salt.
fn derive_convdb_key(password: &str, salt: &[u8]) -> anyhow::Result<Zeroizing<[u8; 32]>> {
    let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32))
        .map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?;
    let argon2 = Argon2::new(Algorithm::Argon2id, Version::default(), params);
    let mut key = Zeroizing::new([0u8; 32]);
    argon2
        .hash_password_into(password.as_bytes(), salt, &mut *key)
        .map_err(|e| anyhow::anyhow!("convdb key derivation: {e}"))?;
    Ok(key)
}

/// Read or create a 16-byte random salt at `salt_path` (mode 0o600).
fn get_or_create_salt(salt_path: &Path) -> anyhow::Result<Vec<u8>> {
    if salt_path.exists() {
        let bytes = std::fs::read(salt_path).context("read convdb salt")?;
        anyhow::ensure!(bytes.len() == SALT_LEN, "invalid convdb salt length");
        return Ok(bytes);
    }
    let mut salt = vec![0u8; SALT_LEN];
    rand::rngs::OsRng.fill_bytes(&mut salt);
    std::fs::write(salt_path, &salt).context("write convdb salt")?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(salt_path, std::fs::Permissions::from_mode(0o600)).ok();
    }
    Ok(salt)
}

// ── ConversationStore ────────────────────────────────────────────────────────

pub struct ConversationStore {
    conn: Connection,
}

impl ConversationStore {
    /// Open or create the conversation database at `db_path`.
    /// If `password` is `Some`, the database is encrypted with SQLCipher using
    /// an Argon2id-derived key. Existing unencrypted databases are migrated
    /// transparently.
    pub fn open(db_path: &Path, password: Option<&str>) -> anyhow::Result<Self> {
        if let Some(parent) = db_path.parent() {
            std::fs::create_dir_all(parent).ok();
        }

        match password {
            Some(pw) => Self::open_encrypted(db_path, pw),
            None => Self::open_plain(db_path),
        }
    }

    fn open_plain(db_path: &Path) -> anyhow::Result<Self> {
        let conn = Connection::open(db_path).context("open conversation db")?;
        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
            .context("set pragmas")?;
        Self::migrate(&conn)?;
        Ok(Self { conn })
    }

    fn open_encrypted(db_path: &Path, password: &str) -> anyhow::Result<Self> {
        let salt_path = PathBuf::from(format!("{}-salt", db_path.display()));
        let already_encrypted = salt_path.exists();

        // Migrate an existing unencrypted database before opening with encryption.
        if db_path.exists() && !already_encrypted {
            Self::migrate_plain_to_encrypted(db_path, &salt_path, password)?;
            // After migration, salt file exists and DB is encrypted — fall through.
        }

        let salt = get_or_create_salt(&salt_path)?;
        let key = derive_convdb_key(password, &salt)?;
        #[allow(clippy::needless_borrows_for_generic_args)]
        let hex_key = Zeroizing::new(hex::encode(&*key));

        let conn = Connection::open(db_path).context("open conversation db")?;
        conn.pragma_update(None, "key", format!("x'{}'", &*hex_key))
            .context("set SQLCipher key")?;
        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
            .context("set pragmas")?;
        Self::migrate(&conn)?;
        Ok(Self { conn })
    }

    /// Migrate an unencrypted `.convdb` to an encrypted one in-place.
    fn migrate_plain_to_encrypted(
        db_path: &Path,
        salt_path: &Path,
        password: &str,
    ) -> anyhow::Result<()> {
        let salt = get_or_create_salt(salt_path)?;
        let key = derive_convdb_key(password, &salt)?;
        #[allow(clippy::needless_borrows_for_generic_args)]
        let hex_key = Zeroizing::new(hex::encode(&*key));

        let enc_path = db_path.with_extension("convdb-enc");

        // Open the existing plaintext database.
        let plain = Connection::open(db_path).context("open plain convdb for migration")?;
        plain.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;").ok();

        // Attach a new encrypted database and export into it.
        // Sanitize the path to prevent SQL injection (ATTACH does not support parameterized paths).
        let enc_path_str = enc_path.display().to_string();
        anyhow::ensure!(
            !enc_path_str.contains('\''),
            "database path must not contain single quotes: {enc_path_str}"
        );
        plain
            .execute_batch(&format!(
                "ATTACH DATABASE '{enc_path_str}' AS encrypted KEY \"x'{}'\";",
                &*hex_key
            ))
            .context("attach encrypted db for migration")?;
        plain
            .execute_batch("SELECT sqlcipher_export('encrypted');")
            .context("sqlcipher_export to encrypted db")?;
        plain
            .execute_batch("DETACH DATABASE encrypted;")
            .context("detach encrypted db")?;

        drop(plain);

        // Swap files: encrypted → original.
        std::fs::rename(&enc_path, db_path).context("replace convdb with encrypted version")?;
        // Clean up WAL/SHM left from the plaintext open.
        let wal = PathBuf::from(format!("{}-wal", db_path.display()));
        let shm = PathBuf::from(format!("{}-shm", db_path.display()));
        std::fs::remove_file(&wal).ok();
        std::fs::remove_file(&shm).ok();

        tracing::info!("migrated conversation database to encrypted storage");
        Ok(())
    }

    fn migrate(conn: &Connection) -> anyhow::Result<()> {
        conn.execute_batch(
            "CREATE TABLE IF NOT EXISTS conversations (
                id              BLOB PRIMARY KEY,
                kind            TEXT NOT NULL,
                display_name    TEXT NOT NULL,
                peer_key        BLOB,
                peer_username   TEXT,
                group_name      TEXT,
                mls_group_blob  BLOB,
                keystore_blob   BLOB,
                member_keys     BLOB,
                unread_count    INTEGER NOT NULL DEFAULT 0,
                last_activity_ms INTEGER NOT NULL DEFAULT 0,
                created_at_ms   INTEGER NOT NULL DEFAULT 0
            );

            CREATE TABLE IF NOT EXISTS messages (
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
                conversation_id BLOB NOT NULL REFERENCES conversations(id),
                message_id      BLOB,
                sender_key      BLOB NOT NULL,
                sender_name     TEXT,
                body            TEXT NOT NULL,
                msg_type        TEXT NOT NULL,
                ref_msg_id      BLOB,
                timestamp_ms    INTEGER NOT NULL,
                is_outgoing     INTEGER NOT NULL DEFAULT 0
            );

            CREATE INDEX IF NOT EXISTS idx_messages_conv
                ON messages(conversation_id, timestamp_ms);

            CREATE TABLE IF NOT EXISTS outbox (
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
                conversation_id BLOB NOT NULL,
                recipient_key   BLOB NOT NULL,
                payload         BLOB NOT NULL,
                created_at_ms   INTEGER NOT NULL,
                retry_count     INTEGER NOT NULL DEFAULT 0,
                status          TEXT NOT NULL DEFAULT 'pending'
            );
            CREATE INDEX IF NOT EXISTS idx_outbox_status
                ON outbox(status, created_at_ms);",
        )
        .context("migrate conversation db")?;

        // Additive migrations for new columns (safe to re-run; errors ignored if column already exists).
        conn.execute_batch("ALTER TABLE conversations ADD COLUMN is_hybrid INTEGER NOT NULL DEFAULT 0;").ok();
        conn.execute_batch("ALTER TABLE conversations ADD COLUMN last_seen_seq INTEGER NOT NULL DEFAULT 0;").ok();

        Ok(())
    }

    // ── Conversation CRUD ────────────────────────────────────────────────

    pub fn save_conversation(&self, conv: &Conversation) -> anyhow::Result<()> {
        let (kind_str, peer_key, peer_username, group_name) = match &conv.kind {
            ConversationKind::Dm {
                peer_key,
                peer_username,
            } => ("dm", Some(peer_key.as_slice()), peer_username.as_deref(), None),
            ConversationKind::Group { name } => ("group", None, None, Some(name.as_str())),
        };
        let member_keys_blob = bincode::serialize(&conv.member_keys)
            .context("serialize member_keys")?;

        self.conn.execute(
            "INSERT INTO conversations
                (id, kind, display_name, peer_key, peer_username, group_name,
                 mls_group_blob, keystore_blob, member_keys, unread_count,
                 last_activity_ms, created_at_ms, is_hybrid, last_seen_seq)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
             ON CONFLICT(id) DO UPDATE SET
                display_name = excluded.display_name,
                mls_group_blob = excluded.mls_group_blob,
                keystore_blob = excluded.keystore_blob,
                member_keys = excluded.member_keys,
                unread_count = excluded.unread_count,
                last_activity_ms = excluded.last_activity_ms,
                is_hybrid = excluded.is_hybrid,
                last_seen_seq = excluded.last_seen_seq",
            params![
                conv.id.0.as_slice(),
                kind_str,
                conv.display_name,
                peer_key,
                peer_username,
                group_name,
                conv.mls_group_blob,
                conv.keystore_blob,
                member_keys_blob,
                conv.unread_count,
                conv.last_activity_ms,
                conv.created_at_ms,
                conv.is_hybrid as i32,
                conv.last_seen_seq as i64,
            ],
        )?;
        Ok(())
    }

    pub fn load_conversation(&self, id: &ConversationId) -> anyhow::Result<Option<Conversation>> {
        self.conn
            .query_row(
                "SELECT kind, display_name, peer_key, peer_username, group_name,
                        mls_group_blob, keystore_blob, member_keys, unread_count,
                        last_activity_ms, created_at_ms, is_hybrid, last_seen_seq
                 FROM conversations WHERE id = ?1",
                params![id.0.as_slice()],
                |row| {
                    let kind_str: String = row.get(0)?;
                    let display_name: String = row.get(1)?;
                    let peer_key: Option<Vec<u8>> = row.get(2)?;
                    let peer_username: Option<String> = row.get(3)?;
                    let group_name: Option<String> = row.get(4)?;
                    let mls_group_blob: Option<Vec<u8>> = row.get(5)?;
                    let keystore_blob: Option<Vec<u8>> = row.get(6)?;
                    let member_keys_blob: Option<Vec<u8>> = row.get(7)?;
                    let unread_count: u32 = row.get(8)?;
                    let last_activity_ms: u64 = row.get(9)?;
                    let created_at_ms: u64 = row.get(10)?;
                    let is_hybrid_int: i32 = row.get(11)?;
                    let last_seen_seq: i64 = row.get(12)?;

                    let kind = if kind_str == "dm" {
                        ConversationKind::Dm {
                            peer_key: peer_key.unwrap_or_default(),
                            peer_username,
                        }
                    } else {
                        ConversationKind::Group {
                            name: group_name.unwrap_or_default(),
                        }
                    };

                    let member_keys: Vec<Vec<u8>> = member_keys_blob
                        .and_then(|b| match bincode::deserialize(&b) {
                            Ok(v) => Some(v),
                            Err(e) => {
                                tracing::warn!(conv = %hex::encode(id.0), "bincode deserialize member_keys failed: {e}");
                                None
                            }
                        })
                        .unwrap_or_default();

                    Ok(Conversation {
                        id: id.clone(),
                        kind,
                        display_name,
                        mls_group_blob,
                        keystore_blob,
                        member_keys,
                        unread_count,
                        last_activity_ms,
                        created_at_ms,
                        is_hybrid: is_hybrid_int != 0,
                        last_seen_seq: last_seen_seq as u64,
                    })
                },
            )
            .optional()
            .context("load conversation")
    }

    pub fn list_conversations(&self) -> anyhow::Result<Vec<Conversation>> {
        let mut stmt = self.conn.prepare(
            "SELECT id, kind, display_name, peer_key, peer_username, group_name,
                    mls_group_blob, keystore_blob, member_keys, unread_count,
                    last_activity_ms, created_at_ms, is_hybrid, last_seen_seq
             FROM conversations ORDER BY last_activity_ms DESC",
        )?;
        let rows = stmt.query_map([], |row| {
            let id_blob: Vec<u8> = row.get(0)?;
            let kind_str: String = row.get(1)?;
            let display_name: String = row.get(2)?;
            let peer_key: Option<Vec<u8>> = row.get(3)?;
            let peer_username: Option<String> = row.get(4)?;
            let group_name: Option<String> = row.get(5)?;
            let mls_group_blob: Option<Vec<u8>> = row.get(6)?;
            let keystore_blob: Option<Vec<u8>> = row.get(7)?;
            let member_keys_blob: Option<Vec<u8>> = row.get(8)?;
            let unread_count: u32 = row.get(9)?;
            let last_activity_ms: u64 = row.get(10)?;
            let created_at_ms: u64 = row.get(11)?;
            let is_hybrid_int: i32 = row.get(12)?;
            let last_seen_seq: i64 = row.get(13)?;

            let id = ConversationId::from_slice(&id_blob).unwrap_or(ConversationId([0; 16]));
            let kind = if kind_str == "dm" {
                ConversationKind::Dm {
                    peer_key: peer_key.unwrap_or_default(),
                    peer_username,
                }
            } else {
                ConversationKind::Group {
                    name: group_name.unwrap_or_default(),
                }
            };
            let member_keys: Vec<Vec<u8>> = member_keys_blob
                .and_then(|b| match bincode::deserialize(&b) {
                    Ok(v) => Some(v),
                    Err(e) => {
                        tracing::warn!(conv = %hex::encode(&id_blob), "bincode deserialize member_keys failed: {e}");
                        None
                    }
                })
                .unwrap_or_default();

            Ok(Conversation {
                id,
                kind,
                display_name,
                mls_group_blob,
                keystore_blob,
                member_keys,
                unread_count,
                last_activity_ms,
                created_at_ms,
                is_hybrid: is_hybrid_int != 0,
                last_seen_seq: last_seen_seq as u64,
            })
        })?;

        let mut convs = Vec::new();
        for row in rows {
            convs.push(row?);
        }
        Ok(convs)
    }

    /// Find a DM conversation by the peer's identity key.
    pub fn find_dm_by_peer(&self, peer_key: &[u8]) -> anyhow::Result<Option<Conversation>> {
        let id_blob: Option<Vec<u8>> = self
            .conn
            .query_row(
                "SELECT id FROM conversations WHERE kind = 'dm' AND peer_key = ?1",
                params![peer_key],
                |row| row.get(0),
            )
            .optional()?;

        match id_blob {
            Some(blob) => {
                let id = ConversationId::from_slice(&blob)
                    .context("invalid conversation id in db")?;
                self.load_conversation(&id)
            }
            None => Ok(None),
        }
    }

    /// Find a group conversation by name.
    pub fn find_group_by_name(&self, name: &str) -> anyhow::Result<Option<Conversation>> {
        let id_blob: Option<Vec<u8>> = self
            .conn
            .query_row(
                "SELECT id FROM conversations WHERE kind = 'group' AND group_name = ?1",
                params![name],
                |row| row.get(0),
            )
            .optional()?;

        match id_blob {
            Some(blob) => {
                let id = ConversationId::from_slice(&blob)
                    .context("invalid conversation id in db")?;
                self.load_conversation(&id)
            }
            None => Ok(None),
        }
    }

    pub fn increment_unread(&self, id: &ConversationId) -> anyhow::Result<()> {
        self.conn.execute(
            "UPDATE conversations SET unread_count = unread_count + 1 WHERE id = ?1",
            params![id.0.as_slice()],
        )?;
        Ok(())
    }

    pub fn reset_unread(&self, id: &ConversationId) -> anyhow::Result<()> {
        self.conn.execute(
            "UPDATE conversations SET unread_count = 0 WHERE id = ?1",
            params![id.0.as_slice()],
        )?;
        Ok(())
    }

    pub fn update_activity(&self, id: &ConversationId, ts_ms: u64) -> anyhow::Result<()> {
        self.conn.execute(
            "UPDATE conversations SET last_activity_ms = ?2 WHERE id = ?1 AND last_activity_ms < ?2",
            params![id.0.as_slice(), ts_ms],
        )?;
        Ok(())
    }

    // ── Message CRUD ─────────────────────────────────────────────────────

    pub fn save_message(&self, msg: &StoredMessage) -> anyhow::Result<()> {
        self.conn.execute(
            "INSERT INTO messages
                (conversation_id, message_id, sender_key, sender_name, body,
                 msg_type, ref_msg_id, timestamp_ms, is_outgoing)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
            params![
                msg.conversation_id.0.as_slice(),
                msg.message_id.as_ref().map(|id| id.as_slice()),
                msg.sender_key,
                msg.sender_name,
                msg.body,
                msg.msg_type,
                msg.ref_msg_id.as_ref().map(|id| id.as_slice()),
                msg.timestamp_ms,
                msg.is_outgoing as i32,
            ],
        )?;
        Ok(())
    }

    pub fn load_recent_messages(
        &self,
        conv_id: &ConversationId,
        limit: usize,
    ) -> anyhow::Result<Vec<StoredMessage>> {
        let mut stmt = self.conn.prepare(
            "SELECT message_id, sender_key, sender_name, body, msg_type,
                    ref_msg_id, timestamp_ms, is_outgoing
             FROM messages
             WHERE conversation_id = ?1
             ORDER BY timestamp_ms DESC
             LIMIT ?2",
        )?;
        let rows = stmt.query_map(params![conv_id.0.as_slice(), limit.min(u32::MAX as usize) as u32], |row| {
            let message_id: Option<Vec<u8>> = row.get(0)?;
            let sender_key: Vec<u8> = row.get(1)?;
            let sender_name: Option<String> = row.get(2)?;
            let body: String = row.get(3)?;
            let msg_type: String = row.get(4)?;
            let ref_msg_id: Option<Vec<u8>> = row.get(5)?;
            let timestamp_ms: u64 = row.get(6)?;
            let is_outgoing: i32 = row.get(7)?;

            fn to_16(v: &[u8]) -> Option<[u8; 16]> {
                if v.len() == 16 {
                    let mut buf = [0u8; 16];
                    buf.copy_from_slice(v);
                    Some(buf)
                } else {
                    None
                }
            }

            Ok(StoredMessage {
                conversation_id: conv_id.clone(),
                message_id: message_id.as_deref().and_then(to_16),
                sender_key,
                sender_name,
                body,
                msg_type,
                ref_msg_id: ref_msg_id.as_deref().and_then(to_16),
                timestamp_ms,
                is_outgoing: is_outgoing != 0,
            })
        })?;

        let mut msgs = Vec::new();
        for row in rows {
            msgs.push(row?);
        }
        // Reverse so oldest first
        msgs.reverse();
        Ok(msgs)
    }

    /// Load all messages for a conversation, oldest first (no limit).
    pub fn load_all_messages(&self, conv_id: &ConversationId) -> anyhow::Result<Vec<StoredMessage>> {
        let mut stmt = self.conn.prepare(
            "SELECT message_id, sender_key, sender_name, body, msg_type,
                    ref_msg_id, timestamp_ms, is_outgoing
             FROM messages
             WHERE conversation_id = ?1
             ORDER BY timestamp_ms ASC, id ASC",
        )?;
        let rows = stmt.query_map(params![conv_id.0.as_slice()], |row| {
            let message_id: Option<Vec<u8>> = row.get(0)?;
            let sender_key: Vec<u8> = row.get(1)?;
            let sender_name: Option<String> = row.get(2)?;
            let body: String = row.get(3)?;
            let msg_type: String = row.get(4)?;
            let ref_msg_id: Option<Vec<u8>> = row.get(5)?;
            let timestamp_ms: u64 = row.get(6)?;
            let is_outgoing: i32 = row.get(7)?;

            fn to_16(v: &[u8]) -> Option<[u8; 16]> {
                if v.len() == 16 {
                    let mut buf = [0u8; 16];
                    buf.copy_from_slice(v);
                    Some(buf)
                } else {
                    None
                }
            }

            Ok(StoredMessage {
                conversation_id: conv_id.clone(),
                message_id: message_id.as_deref().and_then(to_16),
                sender_key,
                sender_name,
                body,
                msg_type,
                ref_msg_id: ref_msg_id.as_deref().and_then(to_16),
                timestamp_ms,
                is_outgoing: is_outgoing != 0,
            })
        })?;

        let mut msgs = Vec::new();
        for row in rows {
            msgs.push(row?);
        }
        Ok(msgs)
    }

    /// Update the body of an existing message (for edits).
    pub fn update_message_body(
        &self,
        conv_id: &ConversationId,
        message_id: &[u8; 16],
        new_body: &str,
    ) -> anyhow::Result<bool> {
        let rows = self.conn.execute(
            "UPDATE messages SET body = ?3 WHERE conversation_id = ?1 AND message_id = ?2",
            params![conv_id.0.as_slice(), message_id.as_slice(), new_body],
        )?;
        Ok(rows > 0)
    }

    /// Mark a message as deleted (sets body to "[deleted]" and msg_type to "deleted").
    pub fn delete_message(
        &self,
        conv_id: &ConversationId,
        message_id: &[u8; 16],
    ) -> anyhow::Result<bool> {
        let rows = self.conn.execute(
            "UPDATE messages SET body = '[deleted]', msg_type = 'deleted' WHERE conversation_id = ?1 AND message_id = ?2",
            params![conv_id.0.as_slice(), message_id.as_slice()],
        )?;
        Ok(rows > 0)
    }

    /// Save a message, deduplicating by message_id within the same conversation.
    /// Returns `true` if the message was saved (new), `false` if it was a duplicate.
    pub fn save_message_dedup(&self, msg: &StoredMessage) -> anyhow::Result<bool> {
        if let Some(ref mid) = msg.message_id {
            let exists: bool = self.conn.query_row(
                "SELECT EXISTS(SELECT 1 FROM messages WHERE message_id = ?1 AND conversation_id = ?2)",
                params![mid.as_slice(), msg.conversation_id.0.as_slice()],
                |row| row.get(0),
            )?;
            if exists {
                return Ok(false);
            }
        }
        self.save_message(msg)?;
        Ok(true)
    }

    // ── Sequence tracking ──────────────────────────────────────────────

    pub fn update_last_seen_seq(&self, id: &ConversationId, seq: u64) -> anyhow::Result<()> {
        self.conn.execute(
            "UPDATE conversations SET last_seen_seq = ?2 WHERE id = ?1 AND last_seen_seq < ?2",
            params![id.0.as_slice(), seq as i64],
        )?;
        Ok(())
    }

    // ── Outbox (offline queue) ────────────────────────────────────────

    pub fn enqueue_outbox(
        &self,
        conv_id: &ConversationId,
        recipient_key: &[u8],
        payload: &[u8],
    ) -> anyhow::Result<()> {
        self.conn.execute(
            "INSERT INTO outbox (conversation_id, recipient_key, payload, created_at_ms)
             VALUES (?1, ?2, ?3, ?4)",
            params![conv_id.0.as_slice(), recipient_key, payload, now_ms() as i64],
        )?;
        Ok(())
    }

    pub fn load_pending_outbox(&self) -> anyhow::Result<Vec<OutboxEntry>> {
        let mut stmt = self.conn.prepare(
            "SELECT id, conversation_id, recipient_key, payload, retry_count
             FROM outbox WHERE status = 'pending' ORDER BY created_at_ms",
        )?;
        let rows = stmt.query_map([], |row| {
            let id: i64 = row.get(0)?;
            let conv_blob: Vec<u8> = row.get(1)?;
            let recipient_key: Vec<u8> = row.get(2)?;
            let payload: Vec<u8> = row.get(3)?;
            let retry_count: u32 = row.get(4)?;
            Ok(OutboxEntry {
                id,
                conversation_id: ConversationId::from_slice(&conv_blob)
                    .unwrap_or(ConversationId([0; 16])),
                recipient_key,
                payload,
                retry_count,
            })
        })?;
        let mut entries = Vec::new();
        for row in rows {
            entries.push(row?);
        }
        Ok(entries)
    }

    pub fn mark_outbox_sent(&self, id: i64) -> anyhow::Result<()> {
        self.conn.execute(
            "UPDATE outbox SET status = 'sent' WHERE id = ?1",
            params![id],
        )?;
        Ok(())
    }

    pub fn mark_outbox_failed(&self, id: i64, retry_count: u32) -> anyhow::Result<()> {
        let new_status = if retry_count > 5 { "failed" } else { "pending" };
        self.conn.execute(
            "UPDATE outbox SET retry_count = ?2, status = ?3 WHERE id = ?1",
            params![id, retry_count, new_status],
        )?;
        Ok(())
    }

    /// Delete messages older than `cutoff_ms` (epoch milliseconds) across all conversations.
    pub fn delete_messages_before(&self, cutoff_ms: u64) -> anyhow::Result<usize> {
        let rows = self.conn.execute(
            "DELETE FROM messages WHERE timestamp_ms < ?1",
            params![cutoff_ms as i64],
        )?;
        Ok(rows)
    }
}

/// An entry in the offline outbox queue.
#[derive(Clone, Debug)]
pub struct OutboxEntry {
    pub id: i64,
    pub conversation_id: ConversationId,
    pub recipient_key: Vec<u8>,
    pub payload: Vec<u8>,
    pub retry_count: u32,
}

pub fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64
}
