use std::path::{Path, PathBuf};
use std::sync::Mutex;

use crypto_primitives::aes::Iv;
use crypto_primitives::key::GenericAesKey;
use crypto_primitives::randomizer_facade::RandomizerFacade;
use log::{debug, warn};
use rusqlite::{Connection, OptionalExtension};

/// Bumped when the on-disk schema changes. A mismatch drops the cached tables
/// (mails + sync_state + event_bus_state) and triggers a full re-sync;
/// encrypted .eml files are keyed by element id and survive the migration.
const SCHEMA_VERSION: &str = "4";

#[derive(Debug, thiserror::Error)]
pub enum StoreError {
    #[error("Database error: {0}")]
    Db(#[from] rusqlite::Error),
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Crypto error: {0}")]
    Crypto(String),
    #[error("Serialization error: {0}")]
    Serde(#[from] serde_json::Error),
}

pub struct MailMetadata {
    pub list_id: String,
    pub element_id: String,
    /// Stable folder id (Tuta `MailSet` element id).
    pub folder_id: String,
    pub subject: String,
    pub sender_name: String,
    pub sender_address: String,
    pub received_date_ms: i64,
    pub unread: bool,
    pub has_details: bool,
    /// Stable IMAP UID within the folder (0 = not yet assigned).
    pub uid: i64,
    pub mail_json: String,
}

pub struct LocalStore {
    conn: Mutex<Connection>,
    storage_key: GenericAesKey,
    mails_dir: PathBuf,
}

/// Turn a free-text search term into a safe FTS5 MATCH expression. Each run of
/// alphanumeric characters becomes a prefix token (`word*`), and tokens are
/// ANDed together (FTS5's default). Because only alphanumerics survive, the
/// result can never contain FTS5 syntax, so it is injection-safe to interpolate
/// as a bound parameter. Returns an empty string when nothing is searchable.
fn fts_match_expr(term: &str) -> String {
    term.split(|c: char| !c.is_alphanumeric())
        .filter(|w| !w.is_empty())
        .map(|w| format!("{w}*"))
        .collect::<Vec<_>>()
        .join(" ")
}

impl LocalStore {
    pub fn open(
        db_path: &Path,
        mails_dir: &Path,
        storage_key: GenericAesKey,
    ) -> Result<Self, StoreError> {
        std::fs::create_dir_all(mails_dir)?;
        if let Some(parent) = db_path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let conn = Connection::open(db_path)?;

        let hex_key = hex::encode(storage_key.as_bytes());
        conn.pragma_update(None, "key", format!("x'{hex_key}'"))?;
        conn.pragma_update(None, "journal_mode", "WAL")?;

        // Migrate: if the stored schema version differs, drop the cache tables.
        conn.execute_batch(
            "CREATE TABLE IF NOT EXISTS store_meta (
                key   TEXT PRIMARY KEY,
                value TEXT NOT NULL
            );",
        )?;
        let version: Option<String> = conn
            .query_row(
                "SELECT value FROM store_meta WHERE key = 'schema_version'",
                [],
                |row| row.get(0),
            )
            .optional()?;
        if version.as_deref() != Some(SCHEMA_VERSION) {
            warn!(
                "Local store schema {:?} != {SCHEMA_VERSION}, dropping cache tables",
                version
            );
            conn.execute_batch(
                "DROP TABLE IF EXISTS mails;
                 DROP TABLE IF EXISTS sync_state;
                 DROP TABLE IF EXISTS event_bus_state;
                 DROP TABLE IF EXISTS mail_fts;",
            )?;
        }

        conn.execute_batch(&format!(
            "CREATE TABLE IF NOT EXISTS mails (
                element_id      TEXT PRIMARY KEY,
                list_id         TEXT NOT NULL,
                folder_id       TEXT NOT NULL,
                subject         TEXT NOT NULL,
                sender_name     TEXT NOT NULL DEFAULT '',
                sender_address  TEXT NOT NULL DEFAULT '',
                received_date_ms INTEGER NOT NULL,
                unread          INTEGER NOT NULL DEFAULT 1,
                has_details     INTEGER NOT NULL DEFAULT 0,
                uid             INTEGER NOT NULL DEFAULT 0,
                mail_json       TEXT NOT NULL
            );
            CREATE INDEX IF NOT EXISTS idx_mails_folder
                ON mails(folder_id, received_date_ms DESC);
            CREATE TABLE IF NOT EXISTS sync_state (
                folder_id     TEXT PRIMARY KEY,
                last_sync_ms  INTEGER NOT NULL DEFAULT 0,
                next_uid      INTEGER NOT NULL DEFAULT 1
            );
            CREATE TABLE IF NOT EXISTS event_bus_state (
                group_id       TEXT PRIMARY KEY,
                last_batch_id  TEXT NOT NULL,
                updated_at_ms  INTEGER NOT NULL DEFAULT 0
            );
            CREATE VIRTUAL TABLE IF NOT EXISTS mail_fts USING fts5(
                element_id UNINDEXED,
                body,
                tokenize = 'unicode61 remove_diacritics 2'
            );
            INSERT OR REPLACE INTO store_meta(key, value) VALUES ('schema_version', '{SCHEMA_VERSION}');"
        ))?;

        debug!("LocalStore opened at {}", db_path.display());

        Ok(Self {
            conn: Mutex::new(conn),
            storage_key,
            mails_dir: mails_dir.to_path_buf(),
        })
    }

    pub fn verify_key(&self) -> bool {
        let conn = self.conn.lock().unwrap();
        conn.query_row(
            "SELECT value FROM store_meta WHERE key = 'schema_version'",
            [],
            |row| row.get::<_, String>(0),
        )
        .is_ok()
    }

    /// Read a value from the generic `store_meta` key-value table.
    pub fn get_meta(&self, key: &str) -> Option<String> {
        let conn = self.conn.lock().unwrap();
        conn.query_row(
            "SELECT value FROM store_meta WHERE key = ?1",
            [key],
            |row| row.get::<_, String>(0),
        )
        .ok()
    }

    /// Write a value into the generic `store_meta` key-value table.
    pub fn set_meta(&self, key: &str, value: &str) -> Result<(), StoreError> {
        let conn = self.conn.lock().unwrap();
        conn.execute(
            "INSERT OR REPLACE INTO store_meta(key, value) VALUES (?1, ?2)",
            [key, value],
        )?;
        Ok(())
    }

    pub fn reset(&self) -> Result<(), StoreError> {
        warn!("Resetting local store — all cached data will be deleted");
        let conn = self.conn.lock().unwrap();
        conn.execute_batch(&format!(
            "DELETE FROM mails;
             DELETE FROM sync_state;
             DELETE FROM store_meta;
             DELETE FROM mail_fts;
             INSERT INTO store_meta(key, value) VALUES ('schema_version', '{SCHEMA_VERSION}');"
        ))?;
        drop(conn);

        if self.mails_dir.exists() {
            for entry in std::fs::read_dir(&self.mails_dir)? {
                let entry = entry?;
                if entry.path().extension().and_then(|e| e.to_str()) == Some("enc") {
                    let _ = std::fs::remove_file(entry.path());
                }
            }
        }
        Ok(())
    }

    pub fn load_folder_metadata(&self, folder_id: &str) -> Result<Vec<MailMetadata>, StoreError> {
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn.prepare(
            "SELECT element_id, list_id, folder_id, subject, sender_name, sender_address,
                    received_date_ms, unread, has_details, uid, mail_json
             FROM mails WHERE folder_id = ?1
             ORDER BY received_date_ms DESC",
        )?;
        let rows = stmt.query_map([folder_id], |row| {
            Ok(MailMetadata {
                element_id: row.get(0)?,
                list_id: row.get(1)?,
                folder_id: row.get(2)?,
                subject: row.get(3)?,
                sender_name: row.get(4)?,
                sender_address: row.get(5)?,
                received_date_ms: row.get(6)?,
                unread: row.get::<_, i64>(7)? != 0,
                has_details: row.get::<_, i64>(8)? != 0,
                uid: row.get(9)?,
                mail_json: row.get(10)?,
            })
        })?;

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

    pub fn upsert_mail_metadata(&self, meta: &MailMetadata) -> Result<(), StoreError> {
        let conn = self.conn.lock().unwrap();
        conn.execute(
            "INSERT INTO mails (element_id, list_id, folder_id, subject, sender_name,
                                sender_address, received_date_ms, unread, has_details, uid, mail_json)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
             ON CONFLICT(element_id) DO UPDATE SET
                folder_id = excluded.folder_id,
                subject = excluded.subject,
                sender_name = excluded.sender_name,
                sender_address = excluded.sender_address,
                received_date_ms = excluded.received_date_ms,
                unread = excluded.unread,
                has_details = excluded.has_details,
                mail_json = excluded.mail_json",
            rusqlite::params![
                meta.element_id,
                meta.list_id,
                meta.folder_id,
                meta.subject,
                meta.sender_name,
                meta.sender_address,
                meta.received_date_ms,
                meta.unread as i64,
                meta.has_details as i64,
                meta.uid,
                meta.mail_json,
            ],
        )?;
        Ok(())
    }

    pub fn upsert_mail_metadata_batch(&self, metas: &[MailMetadata]) -> Result<(), StoreError> {
        let conn = self.conn.lock().unwrap();
        conn.execute_batch("BEGIN IMMEDIATE")?;
        {
            let mut stmt = conn.prepare_cached(
                "INSERT INTO mails (element_id, list_id, folder_id, subject, sender_name,
                                    sender_address, received_date_ms, unread, has_details, uid, mail_json)
                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
                 ON CONFLICT(element_id) DO UPDATE SET
                    folder_id = excluded.folder_id,
                    subject = excluded.subject,
                    sender_name = excluded.sender_name,
                    sender_address = excluded.sender_address,
                    received_date_ms = excluded.received_date_ms,
                    unread = excluded.unread,
                    has_details = CASE WHEN excluded.has_details = 1 THEN 1 ELSE mails.has_details END,
                    mail_json = excluded.mail_json",
            )?;
            for meta in metas {
                stmt.execute(rusqlite::params![
                    meta.element_id,
                    meta.list_id,
                    meta.folder_id,
                    meta.subject,
                    meta.sender_name,
                    meta.sender_address,
                    meta.received_date_ms,
                    meta.unread as i64,
                    meta.has_details as i64,
                    meta.uid,
                    meta.mail_json,
                ])?;
            }
        }
        conn.execute_batch("COMMIT")?;
        Ok(())
    }

    /// Assign stable, monotonic UIDs to the given (new) element ids in a folder.
    /// UIDs are never reused — the per-folder counter only advances — so an IMAP
    /// client's `(UIDVALIDITY, UID)` cache stays valid across bridge restarts.
    /// Ids should be supplied oldest-first so newer mail gets higher UIDs.
    pub fn allocate_folder_uids(
        &self,
        folder_id: &str,
        new_element_ids: &[&str],
    ) -> Result<std::collections::HashMap<String, u32>, StoreError> {
        let conn = self.conn.lock().unwrap();
        let mut next: i64 = conn
            .query_row(
                "SELECT next_uid FROM sync_state WHERE folder_id = ?1",
                [folder_id],
                |row| row.get(0),
            )
            .optional()?
            .unwrap_or(1);

        let mut map = std::collections::HashMap::with_capacity(new_element_ids.len());
        for eid in new_element_ids {
            map.insert((*eid).to_string(), next as u32);
            next += 1;
        }

        conn.execute(
            "INSERT INTO sync_state(folder_id, next_uid) VALUES (?1, ?2)
             ON CONFLICT(folder_id) DO UPDATE SET next_uid = excluded.next_uid",
            rusqlite::params![folder_id, next],
        )?;
        Ok(map)
    }

    pub fn delete_mails_not_in(
        &self,
        folder_id: &str,
        element_ids: &[&str],
    ) -> Result<Vec<String>, StoreError> {
        let conn = self.conn.lock().unwrap();

        let mut deleted = Vec::new();
        {
            let mut stmt = conn.prepare("SELECT element_id FROM mails WHERE folder_id = ?1")?;
            let existing: Vec<String> = stmt
                .query_map([folder_id], |row| row.get(0))?
                .filter_map(|r| r.ok())
                .collect();

            let keep: std::collections::HashSet<&str> = element_ids.iter().copied().collect();

            for eid in existing {
                if !keep.contains(eid.as_str()) {
                    deleted.push(eid);
                }
            }
        }

        if !deleted.is_empty() {
            conn.execute_batch("BEGIN IMMEDIATE")?;
            {
                let mut stmt = conn.prepare_cached("DELETE FROM mails WHERE element_id = ?1")?;
                for eid in &deleted {
                    stmt.execute([eid])?;
                }
            }
            conn.execute_batch("COMMIT")?;
        }

        Ok(deleted)
    }

    pub fn mark_has_details(&self, element_id: &str) -> Result<(), StoreError> {
        let conn = self.conn.lock().unwrap();
        conn.execute(
            "UPDATE mails SET has_details = 1 WHERE element_id = ?1",
            [element_id],
        )?;
        Ok(())
    }

    pub fn write_eml(&self, element_id: &str, rfc2822: &str) -> Result<(), StoreError> {
        let randomizer = RandomizerFacade::from_core(rand_core::OsRng);
        let iv = Iv::generate(&randomizer);
        let encrypted = self
            .storage_key
            .encrypt_data(rfc2822.as_bytes(), iv)
            .map_err(|e| StoreError::Crypto(format!("{e:?}")))?;

        let final_path = self.mails_dir.join(format!("{element_id}.eml.enc"));
        let tmp_path = self.mails_dir.join(format!("{element_id}.eml.enc.tmp"));
        std::fs::write(&tmp_path, &encrypted)?;
        std::fs::rename(&tmp_path, &final_path)?;
        Ok(())
    }

    pub fn read_eml(&self, element_id: &str) -> Result<Option<String>, StoreError> {
        let path = self.mails_dir.join(format!("{element_id}.eml.enc"));
        if !path.exists() {
            return Ok(None);
        }
        let encrypted = std::fs::read(&path)?;
        let decrypted = self
            .storage_key
            .decrypt_data(&encrypted)
            .map_err(|e| StoreError::Crypto(format!("{e:?}")))?;
        String::from_utf8(decrypted)
            .map(Some)
            .map_err(|e| StoreError::Crypto(format!("Invalid UTF-8: {e}")))
    }

    pub fn has_eml(&self, element_id: &str) -> bool {
        self.mails_dir
            .join(format!("{element_id}.eml.enc"))
            .exists()
    }

    pub fn delete_eml(&self, element_id: &str) -> Result<(), StoreError> {
        let path = self.mails_dir.join(format!("{element_id}.eml.enc"));
        if path.exists() {
            std::fs::remove_file(&path)?;
        }
        Ok(())
    }

    /// Index (or re-index) a message's plain-text body for full-text search.
    /// `body_text` should already be stripped of HTML markup. Stored inside the
    /// SQLCipher database, so it is encrypted at rest like everything else.
    pub fn index_body(&self, element_id: &str, body_text: &str) -> Result<(), StoreError> {
        let conn = self.conn.lock().unwrap();
        // FTS5 has no UPSERT; clear any prior row for this id first.
        conn.execute("DELETE FROM mail_fts WHERE element_id = ?1", [element_id])?;
        conn.execute(
            "INSERT INTO mail_fts(element_id, body) VALUES (?1, ?2)",
            [element_id, body_text],
        )?;
        Ok(())
    }

    /// Drop a message from the full-text index (called when a mail is removed).
    pub fn unindex_body(&self, element_id: &str) -> Result<(), StoreError> {
        let conn = self.conn.lock().unwrap();
        conn.execute("DELETE FROM mail_fts WHERE element_id = ?1", [element_id])?;
        Ok(())
    }

    /// Element ids whose indexed body matches a user search term. The term is a
    /// free-text query (one IMAP `BODY`/`TEXT` argument); it is turned into a
    /// safe FTS5 MATCH expression: each word becomes a prefix token so
    /// `factur` finds `facture`/`factures`, and multi-word terms must all be
    /// present. Returns an empty set when the term has no searchable tokens.
    pub fn search_body(&self, term: &str) -> Result<Vec<String>, StoreError> {
        let match_expr = fts_match_expr(term);
        if match_expr.is_empty() {
            return Ok(Vec::new());
        }
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn.prepare("SELECT element_id FROM mail_fts WHERE body MATCH ?1")?;
        let rows = stmt.query_map([&match_expr], |row| row.get::<_, String>(0))?;
        let mut ids = Vec::new();
        for row in rows {
            ids.push(row?);
        }
        Ok(ids)
    }

    /// Number of bodies currently in the full-text index (diagnostics / tests).
    pub fn fts_count(&self) -> Result<usize, StoreError> {
        let conn = self.conn.lock().unwrap();
        let count: i64 = conn.query_row("SELECT COUNT(*) FROM mail_fts", [], |row| row.get(0))?;
        Ok(count as usize)
    }

    pub fn mail_count(&self, folder_id: &str) -> Result<usize, StoreError> {
        let conn = self.conn.lock().unwrap();
        let count: i64 = conn.query_row(
            "SELECT COUNT(*) FROM mails WHERE folder_id = ?1",
            [folder_id],
            |row| row.get(0),
        )?;
        Ok(count as usize)
    }

    pub fn total_count(&self) -> Result<usize, StoreError> {
        let conn = self.conn.lock().unwrap();
        let count: i64 = conn.query_row("SELECT COUNT(*) FROM mails", [], |row| row.get(0))?;
        Ok(count as usize)
    }

    /// Load the last processed event-batch id for every known group.
    pub fn load_event_bus_state(
        &self,
    ) -> Result<std::collections::HashMap<String, String>, StoreError> {
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn.prepare("SELECT group_id, last_batch_id FROM event_bus_state")?;
        let rows = stmt.query_map([], |row| {
            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
        })?;
        let mut out = std::collections::HashMap::new();
        for row in rows {
            let (g, b) = row?;
            out.insert(g, b);
        }
        Ok(out)
    }

    /// Oldest `updated_at_ms` across all event-bus rows, or `None` if empty.
    /// Used at startup to detect a cache older than the server's batch
    /// replay window (~44 days) and force a full re-sync.
    pub fn event_bus_state_min_updated_at_ms(&self) -> Result<Option<i64>, StoreError> {
        let conn = self.conn.lock().unwrap();
        let v: Option<i64> = conn.query_row(
            "SELECT MIN(updated_at_ms) FROM event_bus_state",
            [],
            |row| row.get::<_, Option<i64>>(0),
        )?;
        Ok(v)
    }

    /// Wipe the per-group catch-up cursors. The next reconnect will not pass
    /// `groupsToLastEventBatchIds`, and the syncer will see an empty state
    /// and run the one-shot bootstrap.
    pub fn clear_event_bus_state(&self) -> Result<(), StoreError> {
        let conn = self.conn.lock().unwrap();
        conn.execute("DELETE FROM event_bus_state", [])?;
        Ok(())
    }

    /// Persist the last processed batch id for a group (event-bus catch-up
    /// resumes from this point on the next reconnect).
    pub fn set_event_bus_batch_id(&self, group_id: &str, batch_id: &str) -> Result<(), StoreError> {
        let conn = self.conn.lock().unwrap();
        let now_ms = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_millis() as i64)
            .unwrap_or(0);
        conn.execute(
            "INSERT INTO event_bus_state(group_id, last_batch_id, updated_at_ms)
             VALUES (?1, ?2, ?3)
             ON CONFLICT(group_id) DO UPDATE SET
                last_batch_id = excluded.last_batch_id,
                updated_at_ms = excluded.updated_at_ms",
            rusqlite::params![group_id, batch_id, now_ms],
        )?;
        Ok(())
    }

    /// Refresh the per-mail fields (read/unread, subject, sender, JSON blob)
    /// for every row of this `element_id` — typically a mail lives in one
    /// folder, but Tuta's model allows multi-folder placement, so we update
    /// all matching rows. Folder placement itself is governed by
    /// `MailSetEntry` events.
    pub fn refresh_mail_fields(
        &self,
        element_id: &str,
        subject: &str,
        sender_name: &str,
        sender_address: &str,
        unread: bool,
        mail_json: &str,
    ) -> Result<(), StoreError> {
        let conn = self.conn.lock().unwrap();
        conn.execute(
            "UPDATE mails SET
                subject = ?1,
                sender_name = ?2,
                sender_address = ?3,
                unread = ?4,
                mail_json = ?5
             WHERE element_id = ?6",
            rusqlite::params![
                subject,
                sender_name,
                sender_address,
                unread as i64,
                mail_json,
                element_id,
            ],
        )?;
        Ok(())
    }

    /// Drop every cached mail row that belongs to `folder_id` and return
    /// their element ids — the caller then deletes the matching .eml files.
    /// Used by the event handler when a `MailSet` event tells us a folder
    /// no longer exists on the server.
    pub fn delete_folder_mails(&self, folder_id: &str) -> Result<Vec<String>, StoreError> {
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn.prepare("SELECT element_id FROM mails WHERE folder_id = ?1")?;
        let ids: Vec<String> = stmt
            .query_map([folder_id], |row| row.get::<_, String>(0))?
            .filter_map(|r| r.ok())
            .collect();
        if !ids.is_empty() {
            conn.execute("DELETE FROM mails WHERE folder_id = ?1", [folder_id])?;
        }
        Ok(ids)
    }

    /// Drop a mail entirely (metadata + .eml). Used by the event handler on a
    /// `DELETE` of the underlying mail entity.
    pub fn delete_mail(&self, element_id: &str) -> Result<(), StoreError> {
        {
            let conn = self.conn.lock().unwrap();
            conn.execute("DELETE FROM mails WHERE element_id = ?1", [element_id])?;
        }
        self.delete_eml(element_id)?;
        self.unindex_body(element_id)?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crypto_primitives::aes::Aes256Key;

    fn test_key() -> GenericAesKey {
        let randomizer = RandomizerFacade::from_core(rand_core::OsRng);
        GenericAesKey::Aes256(Aes256Key::generate(&randomizer))
    }

    fn open_memory_store() -> LocalStore {
        let key = test_key();
        let tmp_dir =
            std::env::temp_dir().join(format!("tutabridge_test_{}", rand::random::<u64>()));
        std::fs::create_dir_all(&tmp_dir).unwrap();
        let db_path = tmp_dir.join("test.db");
        let mails_dir = tmp_dir.join("mails");
        LocalStore::open(&db_path, &mails_dir, key).unwrap()
    }

    fn meta(element_id: &str, folder_id: &str, received: i64) -> MailMetadata {
        MailMetadata {
            element_id: element_id.into(),
            list_id: "list1".into(),
            folder_id: folder_id.into(),
            subject: format!("Subject {element_id}"),
            sender_name: "Alice".into(),
            sender_address: "alice@example.com".into(),
            received_date_ms: received,
            unread: true,
            has_details: false,
            uid: 0,
            mail_json: "{}".into(),
        }
    }

    #[test]
    fn fts_index_and_search() {
        let store = open_memory_store();
        store
            .index_body("a", "the quarterly invoice is attached")
            .unwrap();
        store.index_body("b", "lunch plans for friday").unwrap();
        assert_eq!(store.fts_count().unwrap(), 2);

        // Prefix match: "invoic" finds "invoice".
        assert_eq!(store.search_body("invoic").unwrap(), vec!["a".to_string()]);
        // Multi-word ANDs the tokens.
        assert_eq!(
            store.search_body("lunch friday").unwrap(),
            vec!["b".to_string()]
        );
        // No hit.
        assert!(store.search_body("zebra").unwrap().is_empty());
        // Empty / punctuation-only term yields no FTS query.
        assert!(store.search_body("   ").unwrap().is_empty());
    }

    #[test]
    fn fts_reindex_replaces_prior_body() {
        let store = open_memory_store();
        store.index_body("a", "first version about cats").unwrap();
        store.index_body("a", "second version about dogs").unwrap();
        assert_eq!(store.fts_count().unwrap(), 1);
        assert!(store.search_body("cats").unwrap().is_empty());
        assert_eq!(store.search_body("dogs").unwrap(), vec!["a".to_string()]);
    }

    #[test]
    fn fts_unindex_removes_body() {
        let store = open_memory_store();
        store.index_body("a", "removable content").unwrap();
        store.unindex_body("a").unwrap();
        assert!(store.search_body("removable").unwrap().is_empty());
        assert_eq!(store.fts_count().unwrap(), 0);
    }

    #[test]
    fn fts_match_expr_is_injection_safe() {
        // FTS syntax characters never survive into the expression.
        assert_eq!(fts_match_expr(r#"foo" OR bar"#), "foo* OR* bar*");
        assert_eq!(fts_match_expr("a-b.c"), "a* b* c*");
        assert_eq!(fts_match_expr("   "), "");
    }

    #[test]
    fn allocate_uids_are_monotonic_and_per_folder() {
        let store = open_memory_store();
        let m1 = store.allocate_folder_uids("inbox", &["a", "b"]).unwrap();
        assert_eq!(m1["a"], 1);
        assert_eq!(m1["b"], 2);
        // Continues, never reuses, even if "a"/"b" were deleted.
        let m2 = store.allocate_folder_uids("inbox", &["c"]).unwrap();
        assert_eq!(m2["c"], 3);
        // Each folder has its own counter.
        let m3 = store.allocate_folder_uids("custom", &["x"]).unwrap();
        assert_eq!(m3["x"], 1);
    }

    #[test]
    fn test_open_and_verify() {
        let store = open_memory_store();
        assert!(store.verify_key());
    }

    #[test]
    fn test_upsert_and_load_metadata() {
        let store = open_memory_store();
        store
            .upsert_mail_metadata(&meta("abc123", "inbox", 1700000000000))
            .unwrap();

        let loaded = store.load_folder_metadata("inbox").unwrap();
        assert_eq!(loaded.len(), 1);
        assert_eq!(loaded[0].element_id, "abc123");
        assert_eq!(loaded[0].folder_id, "inbox");
        assert!(loaded[0].unread);
        assert!(!loaded[0].has_details);
    }

    #[test]
    fn test_metadata_is_per_folder() {
        let store = open_memory_store();
        store.upsert_mail_metadata(&meta("a", "inbox", 1)).unwrap();
        store
            .upsert_mail_metadata(&meta("b", "custom1", 2))
            .unwrap();

        assert_eq!(store.load_folder_metadata("inbox").unwrap().len(), 1);
        assert_eq!(store.load_folder_metadata("custom1").unwrap().len(), 1);
        assert_eq!(store.load_folder_metadata("missing").unwrap().len(), 0);
    }

    #[test]
    fn test_batch_upsert() {
        let store = open_memory_store();
        let metas: Vec<MailMetadata> = (0..100)
            .map(|i| meta(&format!("mail_{i}"), "inbox", 1700000000000 + i))
            .collect();
        store.upsert_mail_metadata_batch(&metas).unwrap();

        assert_eq!(store.load_folder_metadata("inbox").unwrap().len(), 100);
        assert_eq!(store.mail_count("inbox").unwrap(), 100);
        assert_eq!(store.total_count().unwrap(), 100);
    }

    #[test]
    fn test_delete_mails_not_in() {
        let store = open_memory_store();
        let metas: Vec<MailMetadata> = (0..5)
            .map(|i| meta(&format!("mail_{i}"), "inbox", 1700000000000 + i))
            .collect();
        store.upsert_mail_metadata_batch(&metas).unwrap();

        let keep = vec!["mail_0", "mail_2", "mail_4"];
        let deleted = store.delete_mails_not_in("inbox", &keep).unwrap();
        assert_eq!(deleted.len(), 2);
        assert!(deleted.contains(&"mail_1".to_string()));
        assert!(deleted.contains(&"mail_3".to_string()));
        assert_eq!(store.mail_count("inbox").unwrap(), 3);
    }

    #[test]
    fn test_eml_write_read_roundtrip() {
        let store = open_memory_store();
        let rfc2822 = "From: test@example.com\r\nSubject: Hello\r\n\r\nBody text here";
        store.write_eml("test_mail", rfc2822).unwrap();
        assert_eq!(
            store.read_eml("test_mail").unwrap(),
            Some(rfc2822.to_string())
        );
    }

    #[test]
    fn test_eml_read_nonexistent() {
        let store = open_memory_store();
        assert_eq!(store.read_eml("nonexistent").unwrap(), None);
    }

    #[test]
    fn test_eml_delete() {
        let store = open_memory_store();
        store.write_eml("to_delete", "content").unwrap();
        assert!(store.read_eml("to_delete").unwrap().is_some());
        store.delete_eml("to_delete").unwrap();
        assert!(store.read_eml("to_delete").unwrap().is_none());
    }

    #[test]
    fn test_reset() {
        let store = open_memory_store();
        store
            .upsert_mail_metadata(&meta("abc", "inbox", 0))
            .unwrap();
        store.write_eml("abc", "content").unwrap();

        store.reset().unwrap();
        assert_eq!(store.total_count().unwrap(), 0);
        assert!(store.read_eml("abc").unwrap().is_none());
        assert!(store.verify_key());
    }

    #[test]
    fn event_bus_state_min_updated_at_and_clear() {
        let store = open_memory_store();
        assert!(store.event_bus_state_min_updated_at_ms().unwrap().is_none());

        store.set_event_bus_batch_id("g1", "b1").unwrap();
        // First write: min == that row's timestamp; just assert presence.
        let min1 = store.event_bus_state_min_updated_at_ms().unwrap();
        assert!(min1.is_some());
        // Sleep a millisecond so the next write has a strictly larger ts.
        std::thread::sleep(std::time::Duration::from_millis(2));
        store.set_event_bus_batch_id("g2", "b2").unwrap();
        let min2 = store.event_bus_state_min_updated_at_ms().unwrap();
        // Still the original (oldest) row.
        assert_eq!(min1, min2);

        store.clear_event_bus_state().unwrap();
        assert!(store.load_event_bus_state().unwrap().is_empty());
        assert!(store.event_bus_state_min_updated_at_ms().unwrap().is_none());
    }

    #[test]
    fn event_bus_state_roundtrip() {
        let store = open_memory_store();
        assert!(store.load_event_bus_state().unwrap().is_empty());

        store.set_event_bus_batch_id("group1", "batchA").unwrap();
        store.set_event_bus_batch_id("group2", "batchX").unwrap();

        let s = store.load_event_bus_state().unwrap();
        assert_eq!(s.get("group1"), Some(&"batchA".to_string()));
        assert_eq!(s.get("group2"), Some(&"batchX".to_string()));

        // Upsert overwrites.
        store.set_event_bus_batch_id("group1", "batchB").unwrap();
        let s = store.load_event_bus_state().unwrap();
        assert_eq!(s.get("group1"), Some(&"batchB".to_string()));
    }

    #[test]
    fn delete_folder_mails_returns_ids_and_drops_rows() {
        let store = open_memory_store();
        store.upsert_mail_metadata(&meta("a", "doomed", 1)).unwrap();
        store.upsert_mail_metadata(&meta("b", "doomed", 2)).unwrap();
        store.upsert_mail_metadata(&meta("c", "kept", 3)).unwrap();
        let ids = store.delete_folder_mails("doomed").unwrap();
        let mut ids = ids;
        ids.sort();
        assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
        assert_eq!(store.mail_count("doomed").unwrap(), 0);
        assert_eq!(store.mail_count("kept").unwrap(), 1);
        // No rows for an unknown folder, returns empty.
        assert!(store.delete_folder_mails("missing").unwrap().is_empty());
    }

    #[test]
    fn delete_mail_removes_metadata_and_eml() {
        let store = open_memory_store();
        store
            .upsert_mail_metadata(&meta("del1", "inbox", 0))
            .unwrap();
        store.write_eml("del1", "content").unwrap();
        assert_eq!(store.mail_count("inbox").unwrap(), 1);
        assert!(store.has_eml("del1"));

        store.delete_mail("del1").unwrap();
        assert_eq!(store.mail_count("inbox").unwrap(), 0);
        assert!(!store.has_eml("del1"));
    }

    #[test]
    fn test_mark_has_details() {
        let store = open_memory_store();
        store
            .upsert_mail_metadata(&meta("det", "inbox", 0))
            .unwrap();
        assert!(!store.load_folder_metadata("inbox").unwrap()[0].has_details);

        store.mark_has_details("det").unwrap();
        assert!(store.load_folder_metadata("inbox").unwrap()[0].has_details);
    }
}
