/**
 * WebAssembly SQLite metadata index for mailx (Android/browser).
 * API-compatible with @bobfrankston/mailx-store's MailxDB.
 * Uses sql.js (SQLite compiled to WebAssembly) for in-browser SQLite.
 *
 * Database is persisted to IndexedDB on every write operation.
 * Bodies are stored in IndexedDB via WebMessageStore (not filesystem).
 */
import initSqlJs from "sql.js";
// Inline the WebAssembly binary at bundle time (esbuild loader: ".wasm" =
// "binary"). The import resolves to a Uint8Array of the wasm bytes. We
// pass these directly to initSqlJs via `wasmBinary`, skipping sql.js's
// runtime fetch of the .wasm — Android WebView can't compile WASM
// fetched from `file://` (no application/wasm MIME, and the ArrayBuffer
// fallback fetch also blocks). Found via jserv logit 2026-05-14 v1.1.6.1:
//   "wasm streaming compile failed → fallback ArrayBuffer instantiation
//    → both async and sync fetching of the wasm failed". Embedding makes
// the android-bootstrap bundle ~700 KB larger but eliminates the runtime
// fetch dependency entirely. Desktop (msger) doesn't load this bundle —
// the check `if (!window.mailxapi)` in index.html routes desktop past
// android-bootstrap.js — so the extra bytes only ship in the Android APK.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import sqlWasmBinary from "sql.js/dist/sql-wasm.wasm";
import { parseSearchQuery, CONTACT_RULES } from "@bobfrankston/mailx-types";
/** Junk-contact filter regexes — sourced from contact-rules.jsonc so
 *  desktop and Android share one rule definition. */
const JUNK_LOCAL_RE = new RegExp(CONTACT_RULES.junk.localExact, "i");
const JUNK_LOCAL_SUFFIX_RE = new RegExp(CONTACT_RULES.junk.localSuffix, "i");
const JUNK_LOCAL_PREFIX_RE = new RegExp(CONTACT_RULES.junk.localPrefix, "i");
const JUNK_DOMAIN_RE = new RegExp(CONTACT_RULES.junk.domain, "i");
const JUNK_LOCAL_ONEOFF_RE = new RegExp(CONTACT_RULES.junk.localOneoff, "i");
function isJunkContact(email, name) {
    const lower = (email || "").toLowerCase();
    const at = lower.indexOf("@");
    const local = at >= 0 ? lower.slice(0, at) : lower;
    const domain = at >= 0 ? lower.slice(at + 1) : "";
    if (JUNK_LOCAL_RE.test(local))
        return true;
    if (JUNK_LOCAL_SUFFIX_RE.test(local))
        return true;
    if (JUNK_LOCAL_PREFIX_RE.test(local))
        return true;
    if (JUNK_LOCAL_ONEOFF_RE.test(local))
        return true;
    if (domain && JUNK_DOMAIN_RE.test(domain))
        return true;
    if (local.length < 2)
        return true;
    const lname = (name || "").trim().toLowerCase();
    if (lname.includes("mailer-daemon") || lname.includes("postmaster"))
        return true;
    return false;
}
const SCHEMA = `
    CREATE TABLE IF NOT EXISTS accounts (
        id TEXT PRIMARY KEY,
        name TEXT NOT NULL,
        email TEXT NOT NULL,
        config_json TEXT NOT NULL,
        last_sync INTEGER DEFAULT 0
    );

    CREATE TABLE IF NOT EXISTS folders (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        account_id TEXT NOT NULL REFERENCES accounts(id),
        path TEXT NOT NULL,
        name TEXT NOT NULL,
        special_use TEXT,
        delimiter TEXT DEFAULT '/',
        total_count INTEGER DEFAULT 0,
        unread_count INTEGER DEFAULT 0,
        uidvalidity INTEGER DEFAULT 0,
        highest_modseq TEXT DEFAULT '0',
        UNIQUE(account_id, path)
    );

    CREATE TABLE IF NOT EXISTS messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        account_id TEXT NOT NULL,
        folder_id INTEGER NOT NULL REFERENCES folders(id),
        uid INTEGER NOT NULL,
        message_id TEXT,
        in_reply_to TEXT,
        refs TEXT,
        date INTEGER NOT NULL,
        sent_date INTEGER,
        subject TEXT DEFAULT '',
        from_address TEXT DEFAULT '',
        from_name TEXT DEFAULT '',
        to_json TEXT DEFAULT '[]',
        cc_json TEXT DEFAULT '[]',
        flags_json TEXT DEFAULT '[]',
        size INTEGER DEFAULT 0,
        has_attachments INTEGER DEFAULT 0,
        preview TEXT DEFAULT '',
        body_path TEXT,
        cached_at INTEGER NOT NULL,
        UNIQUE(account_id, folder_id, uid)
    );

    CREATE INDEX IF NOT EXISTS idx_messages_folder_date
        ON messages(account_id, folder_id, date DESC);

    CREATE INDEX IF NOT EXISTS idx_messages_message_id
        ON messages(message_id);

    CREATE TABLE IF NOT EXISTS queue (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        status TEXT NOT NULL DEFAULT 'pending',
        created_at INTEGER NOT NULL,
        send_after INTEGER NOT NULL,
        attempts INTEGER DEFAULT 0,
        last_attempt INTEGER DEFAULT 0,
        error TEXT,
        from_account TEXT NOT NULL,
        to_json TEXT NOT NULL,
        cc_json TEXT DEFAULT '[]',
        bcc_json TEXT DEFAULT '[]',
        subject TEXT DEFAULT '',
        body_html TEXT DEFAULT '',
        body_text TEXT DEFAULT '',
        in_reply_to TEXT,
        refs TEXT
    );

    CREATE TABLE IF NOT EXISTS contacts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        source TEXT NOT NULL DEFAULT 'sent',
        google_id TEXT,
        name TEXT DEFAULT '',
        email TEXT NOT NULL,
        organization TEXT DEFAULT '',
        last_used INTEGER DEFAULT 0,
        use_count INTEGER DEFAULT 0,
        updated_at INTEGER NOT NULL,
        UNIQUE(email)
    );

    CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
    CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);

    CREATE TABLE IF NOT EXISTS sync_actions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        account_id TEXT NOT NULL,
        action TEXT NOT NULL,
        uid INTEGER,
        folder_id INTEGER,
        target_folder_id INTEGER,
        flags_json TEXT,
        raw_message TEXT,
        created_at INTEGER NOT NULL,
        attempts INTEGER DEFAULT 0,
        last_error TEXT,
        UNIQUE(account_id, action, uid, folder_id)
    );

    -- Calendar events two-way cache (Android parity with desktop's
    -- packages/mailx-store/db.ts calendar_events table). uuid = stable
    -- local identity, provider_id = Google Calendar event id when known,
    -- dirty = local edit not yet pushed, deleted = tombstone pending delete.
    CREATE TABLE IF NOT EXISTS calendar_events (
        uuid TEXT PRIMARY KEY,
        account_id TEXT NOT NULL,
        provider_id TEXT,
        calendar_id TEXT DEFAULT 'primary',
        title TEXT NOT NULL DEFAULT '',
        start_ms INTEGER NOT NULL,
        end_ms INTEGER NOT NULL,
        all_day INTEGER DEFAULT 0,
        location TEXT DEFAULT '',
        notes TEXT DEFAULT '',
        etag TEXT,
        last_synced INTEGER DEFAULT 0,
        dirty INTEGER DEFAULT 0,
        deleted INTEGER DEFAULT 0,
        updated_at INTEGER NOT NULL
    );
    CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);

    CREATE TABLE IF NOT EXISTS tasks (
        uuid TEXT PRIMARY KEY,
        account_id TEXT NOT NULL,
        provider_id TEXT,
        list_id TEXT DEFAULT '@default',
        title TEXT NOT NULL DEFAULT '',
        notes TEXT DEFAULT '',
        due_ms INTEGER,
        completed_ms INTEGER,
        etag TEXT,
        last_synced INTEGER DEFAULT 0,
        dirty INTEGER DEFAULT 0,
        deleted INTEGER DEFAULT 0,
        updated_at INTEGER NOT NULL
    );
    CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);

    -- Generic store-sync queue for non-message domains (calendar/tasks/contacts).
    CREATE TABLE IF NOT EXISTS store_sync (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        kind TEXT NOT NULL,
        op TEXT NOT NULL,
        account_id TEXT NOT NULL,
        target_uuid TEXT NOT NULL,
        payload TEXT,
        attempts INTEGER DEFAULT 0,
        last_error TEXT,
        created_at INTEGER NOT NULL,
        UNIQUE(kind, target_uuid, op)
    );
`;
const IDB_NAME = "mailx-sqldb";
const IDB_STORE = "database";
const IDB_KEY = "mailx.db";
// ── IndexedDB persistence ──
function openIdb() {
    return new Promise((resolve, reject) => {
        const req = indexedDB.open(IDB_NAME, 1);
        req.onupgradeneeded = () => {
            const db = req.result;
            if (!db.objectStoreNames.contains(IDB_STORE)) {
                db.createObjectStore(IDB_STORE);
            }
        };
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
    });
}
async function loadDbFromIdb() {
    const idb = await openIdb();
    return new Promise((resolve, reject) => {
        const tx = idb.transaction(IDB_STORE, "readonly");
        const req = tx.objectStore(IDB_STORE).get(IDB_KEY);
        req.onsuccess = () => resolve(req.result ? new Uint8Array(req.result) : null);
        req.onerror = () => reject(req.error);
    });
}
async function saveDbToIdb(data) {
    const idb = await openIdb();
    return new Promise((resolve, reject) => {
        const tx = idb.transaction(IDB_STORE, "readwrite");
        tx.objectStore(IDB_STORE).put(data.buffer, IDB_KEY);
        tx.oncomplete = () => resolve();
        tx.onerror = () => reject(tx.error);
    });
}
async function clearIdb() {
    const idb = await openIdb();
    return new Promise((resolve, reject) => {
        const tx = idb.transaction(IDB_STORE, "readwrite");
        tx.objectStore(IDB_STORE).delete(IDB_KEY);
        tx.oncomplete = () => resolve();
        tx.onerror = () => reject(tx.error);
    });
}
// ── Helper: convert sql.js result to array of objects ──
function resultToRows(result) {
    if (!result || result.length === 0)
        return [];
    const { columns, values } = result[0];
    return values.map((row) => {
        const obj = {};
        columns.forEach((col, i) => { obj[col] = row[i]; });
        return obj;
    });
}
export class WebMailxDB {
    db = null;
    ready;
    saveTimer = null;
    constructor(dbName = "mailx") {
        this.ready = this.init(dbName);
    }
    async init(_dbName) {
        // wasmBinary is the bundle-inlined Uint8Array (see import + comment
        // at top of file). Bypasses sql.js's runtime fetch entirely — works
        // identically on Android WebView (file://), browser, desktop msger,
        // and Node tests. locateFile is omitted because nothing else in the
        // sql.js init path fetches anymore once wasmBinary is provided.
        // sql.js's TypeScript declarations don't include `wasmBinary` in
        // the InitSqlJsOptions type, but Emscripten honors it at runtime —
        // the bytes go straight into WebAssembly.instantiate without a
        // fetch. Cast to `any` to bypass the type check.
        const SQL = await initSqlJs({
            wasmBinary: sqlWasmBinary,
        });
        // Try to load existing DB from IndexedDB
        const existing = await loadDbFromIdb();
        if (existing) {
            this.db = new SQL.Database(existing);
        }
        else {
            this.db = new SQL.Database();
        }
        this.db.run("PRAGMA foreign_keys = ON");
        this.db.run(SCHEMA);
        // Additive column for DBs created before sent_date existed. sql.js has
        // no ADD COLUMN IF NOT EXISTS; the throw on "duplicate column" is the
        // no-op path. Old rows stay NULL (list falls back to received date)
        // and heal as syncs re-touch them — no backfill pass, per the
        // cache-not-database rule.
        try {
            this.db.run("ALTER TABLE messages ADD COLUMN sent_date INTEGER");
        }
        catch { /* exists */ }
        this.scheduleSave();
    }
    /** Wait for DB initialization */
    async waitReady() {
        await this.ready;
        this.runOneShotJunkContactPurge();
    }
    /** One-shot junk-contact purge — see desktop equivalent in mailx-store/db.ts.
     *  Bump TARGET when filter rules tighten so we sweep historical rows once
     *  per device. Flag stored in localStorage (the web DB has no kv table). */
    runOneShotJunkContactPurge() {
        const TARGET = CONTACT_RULES.rulesVersion;
        try {
            const last = localStorage.getItem("mailx-contacts-purge-v");
            if (last === TARGET)
                return;
            const rows = this.all("SELECT id, email, name FROM contacts WHERE source IN ('discovered','sent','received')");
            let dropped = 0;
            for (const r of rows) {
                if (isJunkContact(r.email || "", r.name || "")) {
                    this.run("DELETE FROM contacts WHERE id = ?", [r.id]);
                    dropped++;
                }
            }
            localStorage.setItem("mailx-contacts-purge-v", TARGET);
            if (dropped > 0)
                console.log(`  [contacts] one-shot purge: dropped ${dropped} junk rows (${TARGET})`);
        }
        catch (e) {
            console.warn(`[contacts] one-shot purge failed: ${e.message}`);
        }
    }
    /** Persist DB to IndexedDB (debounced — batches rapid writes) */
    scheduleSave() {
        if (this.saveTimer)
            return;
        this.saveTimer = setTimeout(async () => {
            this.saveTimer = null;
            if (this.db) {
                const data = this.db.export();
                await saveDbToIdb(data);
            }
        }, 1000);
    }
    /** Force immediate persist */
    async flush() {
        if (this.saveTimer) {
            clearTimeout(this.saveTimer);
            this.saveTimer = null;
        }
        if (this.db) {
            const data = this.db.export();
            await saveDbToIdb(data);
        }
    }
    close() {
        if (this.db) {
            this.flush();
            this.db.close();
            this.db = null;
        }
    }
    run(sql, params) {
        this.db.run(sql, params);
        this.scheduleSave();
    }
    get(sql, params) {
        const result = this.db.exec(sql, params);
        const rows = resultToRows(result);
        return rows[0] || null;
    }
    all(sql, params) {
        const result = this.db.exec(sql, params);
        return resultToRows(result);
    }
    lastId() {
        const row = this.get("SELECT last_insert_rowid() as id");
        return row?.id || 0;
    }
    // ── Accounts ──
    upsertAccount(id, name, email, configJson) {
        this.run(`INSERT INTO accounts (id, name, email, config_json)
             VALUES (?, ?, ?, ?)
             ON CONFLICT(id) DO UPDATE SET name=?, email=?, config_json=?`, [id, name, email, configJson, name, email, configJson]);
    }
    getAccounts() {
        return this.all("SELECT id, name, email, last_sync as lastSync FROM accounts");
    }
    getAccountConfigs() {
        return this.all("SELECT id, name, email, config_json as configJson FROM accounts");
    }
    updateLastSync(accountId, timestamp) {
        this.run("UPDATE accounts SET last_sync = ? WHERE id = ?", [timestamp, accountId]);
    }
    // ── Folders ──
    upsertFolder(accountId, folderPath, name, specialUse, delimiter) {
        const existing = this.get("SELECT id FROM folders WHERE account_id = ? AND path = ?", [accountId, folderPath]);
        if (existing) {
            this.run("UPDATE folders SET name = ?, special_use = ?, delimiter = ? WHERE id = ?", [name, specialUse, delimiter, existing.id]);
            return existing.id;
        }
        this.run("INSERT INTO folders (account_id, path, name, special_use, delimiter) VALUES (?, ?, ?, ?, ?)", [accountId, folderPath, name, specialUse, delimiter]);
        return this.lastId();
    }
    getFolders(accountId) {
        const rows = this.all("SELECT * FROM folders WHERE account_id = ? ORDER BY path", [accountId]);
        return rows.map(r => ({
            id: r.id, accountId: r.account_id, path: r.path, name: r.name,
            specialUse: r.special_use, delimiter: r.delimiter,
            totalCount: r.total_count, unreadCount: r.unread_count,
            children: []
        }));
    }
    deleteFolder(folderId) {
        this.run("DELETE FROM messages WHERE folder_id = ?", [folderId]);
        this.run("DELETE FROM folders WHERE id = ?", [folderId]);
    }
    markFolderRead(folderId) {
        this.run(`UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]')
             WHERE folder_id = ? AND flags_json NOT LIKE '%\\\\Seen%'`, [folderId]);
        this.recalcFolderCounts(folderId);
    }
    deleteAllMessages(accountId, folderId) {
        this.run("DELETE FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]);
        this.recalcFolderCounts(folderId);
    }
    updateFolderCounts(folderId, total, unread) {
        this.run("UPDATE folders SET total_count = ?, unread_count = ? WHERE id = ?", [total, unread, folderId]);
    }
    updateFolderSync(folderId, uidvalidity, highestModseq) {
        this.run("UPDATE folders SET uidvalidity = ?, highest_modseq = ? WHERE id = ?", [uidvalidity, highestModseq, folderId]);
    }
    getFolderSync(folderId) {
        const row = this.get("SELECT uidvalidity, highest_modseq as highestModseq FROM folders WHERE id = ?", [folderId]);
        return row || { uidvalidity: 0, highestModseq: "0" };
    }
    // ── Messages ──
    upsertMessage(msg) {
        const existing = this.get("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?", [msg.accountId, msg.folderId, msg.uid]);
        if (existing) {
            // COALESCE heals pre-sent_date rows the next time a sync touches
            // them, without ever clobbering a real value with a fallback.
            this.run(`UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, sent_date = COALESCE(sent_date, ?), cached_at = ? WHERE id = ?`, [JSON.stringify(msg.flags), msg.preview, msg.bodyPath, msg.sentDate ?? msg.date, Date.now(), existing.id]);
            return existing.id;
        }
        this.run(`INSERT INTO messages (
                account_id, folder_id, uid, message_id, in_reply_to, refs,
                date, sent_date, subject, from_address, from_name, to_json, cc_json,
                flags_json, size, has_attachments, preview, body_path, cached_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
            msg.accountId, msg.folderId, msg.uid, msg.messageId,
            msg.inReplyTo, JSON.stringify(msg.references),
            msg.date, msg.sentDate ?? msg.date, msg.subject, msg.from.address, msg.from.name,
            JSON.stringify(msg.to), JSON.stringify(msg.cc),
            JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0,
            msg.preview, msg.bodyPath, Date.now()
        ]);
        return this.lastId();
    }
    getMessages(query) {
        const page = query.page || 1;
        const pageSize = query.pageSize || 50;
        const offset = (page - 1) * pageSize;
        const sortCol = query.sort === "from" ? "from_name" : query.sort === "subject" ? "subject" : "date";
        const sortDir = query.sortDir || "desc";
        let where = "account_id = ? AND folder_id = ?";
        const params = [query.accountId, query.folderId];
        if (query.search) {
            where += " AND (subject LIKE ? OR from_name LIKE ? OR from_address LIKE ?)";
            const term = `%${query.search}%`;
            params.push(term, term, term);
        }
        const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`, params);
        const total = countRow?.cnt || 0;
        const rows = this.all(`SELECT * FROM messages WHERE ${where} ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
        return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
    }
    getUnifiedInbox(page = 1, pageSize = 50) {
        const offset = (page - 1) * pageSize;
        const inboxRows = this.all("SELECT id FROM folders WHERE special_use = 'inbox'");
        if (inboxRows.length === 0)
            return { items: [], total: 0, page, pageSize };
        const ids = inboxRows.map(r => r.id);
        const placeholders = ids.map(() => "?").join(",");
        const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE folder_id IN (${placeholders})`, ids);
        const total = countRow?.cnt || 0;
        const rows = this.all(`SELECT * FROM messages WHERE folder_id IN (${placeholders}) ORDER BY date DESC LIMIT ? OFFSET ?`, [...ids, pageSize, offset]);
        return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
    }
    getMessageByUid(accountId, uid, folderId) {
        const sql = folderId != null
            ? "SELECT * FROM messages WHERE account_id = ? AND uid = ? AND folder_id = ?"
            : "SELECT * FROM messages WHERE account_id = ? AND uid = ?";
        const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
        const r = this.get(sql, params);
        if (!r)
            return null;
        return this.rowToEnvelope(r);
    }
    getMessageBodyPath(accountId, uid) {
        const r = this.get("SELECT body_path FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
        return r?.body_path || "";
    }
    /** The provider's own message id (Gmail message id) for a uid. Passed to
     *  the Gmail provider's trash/move so it doesn't fall back to a capped
     *  list-and-hash search that misses messages in a large mailbox. "" when
     *  unknown — the provider then uses its (capped) fallback. */
    getProviderId(accountId, uid) {
        const r = this.get("SELECT provider_id FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
        return r?.provider_id || "";
    }
    /** folderId required — see main mailx-store/db.ts for why. */
    updateMessageFlags(accountId, folderId, uid, flags) {
        this.run("UPDATE messages SET flags_json = ? WHERE account_id = ? AND folder_id = ? AND uid = ?", [JSON.stringify(flags), accountId, folderId, uid]);
    }
    /** folderId REQUIRED — see main mailx-store/db.ts: a (account, uid) write
     *  cross-wires body_path across every folder sharing the numeric UID. */
    updateBodyPath(accountId, folderId, uid, bodyPath) {
        this.run("UPDATE messages SET body_path = ? WHERE account_id = ? AND folder_id = ? AND uid = ?", [bodyPath, accountId, folderId, uid]);
    }
    getMessagesWithoutBody(accountId, limit = 50) {
        // "idb:<acct>/<folder>/<uid>" means the body is cached in IndexedDB via
        // WebMessageStore. Anything else (NULL, "", "gmail:<id>", legacy paths)
        // still needs fetching.
        // Prefetch order: smallest first, NULL/0 sizes last, recent within
        // tiebreak. Matches the desktop store — short notification mails
        // dominate the cache fill on slow Android networks instead of one
        // multi-megabyte attachment monopolizing bandwidth.
        return this.all("SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path NOT LIKE 'idb:%') ORDER BY (size IS NULL OR size = 0), size ASC, date DESC LIMIT ?", [accountId, limit]);
    }
    getHighestUid(accountId, folderId) {
        const r = this.get("SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]);
        return r?.maxUid || 0;
    }
    getOldestDate(accountId, folderId) {
        const r = this.get("SELECT MIN(date) as minDate FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]);
        return r?.minDate || 0;
    }
    getMessageCount(accountId, folderId) {
        const r = this.get("SELECT count(*) as cnt FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]);
        return r?.cnt || 0;
    }
    getUidsForFolder(accountId, folderId) {
        return this.all("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]).map(r => r.uid);
    }
    /** folderId REQUIRED. See main mailx-store/db.ts for why: IMAP UIDs are
     *  per-folder, so DELETE WHERE (account, uid) would wipe the same UID
     *  out of every folder. The pre-2026-05-27 implementation took only
     *  (accountId, uid) — that was the 70k-row data-loss bug. */
    deleteMessage(accountId, folderId, uid) {
        this.run("DELETE FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?", [accountId, folderId, uid]);
        this.recalcFolderCounts(folderId);
    }
    recalcFolderCounts(folderId) {
        const counts = this.get(`SELECT COUNT(*) as total,
                    SUM(CASE WHEN flags_json NOT LIKE '%\\\\Seen%' THEN 1 ELSE 0 END) as unread
             FROM messages WHERE folder_id = ?`, [folderId]);
        this.updateFolderCounts(folderId, counts?.total || 0, counts?.unread || 0);
    }
    beginTransaction() { this.db.run("BEGIN"); }
    commitTransaction() { this.db.run("COMMIT"); this.scheduleSave(); }
    rollbackTransaction() { this.db.run("ROLLBACK"); }
    // ── Contacts ──
    /** Delete every contact row tied to a single Google contact identity
     *  (resourceName like "people/c12345"). Used by the People-API
     *  incremental sync when Google flags a contact as deleted. A single
     *  Google contact can have multiple email-rows; remove them all. */
    deleteContactByGoogleId(googleId) {
        if (!googleId)
            return 0;
        const before = this.get("SELECT COUNT(*) as c FROM contacts WHERE google_id = ?", [googleId])?.c || 0;
        this.run("DELETE FROM contacts WHERE google_id = ?", [googleId]);
        return before;
    }
    /** Stamp the google_id + organization on an already-recorded contact row.
     *  Web side mirrors mailx-store/db.ts — the Google sync calls
     *  `recordSentAddress` first to upsert, then this to attach the
     *  Google identity for incremental delete tracking. */
    setContactGoogleId(email, googleId, organization) {
        if (!email)
            return;
        this.run("UPDATE contacts SET source = 'google', google_id = ?, organization = ?, updated_at = ? WHERE email = ?", [googleId, organization || "", Date.now(), email]);
    }
    recordSentAddress(name, email) {
        if (isJunkContact(email, name))
            return;
        const now = Date.now();
        const existing = this.get("SELECT id FROM contacts WHERE email = ?", [email]);
        if (existing) {
            this.run("UPDATE contacts SET name = CASE WHEN ? != '' THEN ? ELSE name END, last_used = ?, use_count = use_count + 1, updated_at = ? WHERE email = ?", [name, name, now, now, email]);
        }
        else {
            this.run("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('sent', ?, ?, ?, 1, ?)", [name, email, now, now]);
        }
    }
    seedContactsFromMessages() {
        const now = Date.now();
        const rows = this.all(`SELECT from_name, from_address, COUNT(*) as cnt, MAX(date) as last
             FROM messages WHERE from_address != '' GROUP BY from_address`);
        let added = 0;
        for (const r of rows) {
            if (isJunkContact(r.from_address, r.from_name || ""))
                continue;
            const existing = this.get("SELECT id FROM contacts WHERE email = ?", [r.from_address]);
            if (!existing) {
                this.run("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)", [r.from_name || "", r.from_address, r.last, r.cnt, now]);
                added++;
            }
        }
        return added;
    }
    /** Emails the user denylisted (contacts.jsonc#denylist). Filtered out of
     *  autocomplete. Populated by web-service's loadContactsConfig. */
    _denylist = new Set();
    setContactsDenylist(emails) {
        this._denylist = new Set((emails || [])
            .map(e => (e || "").trim().toLowerCase())
            .filter(Boolean));
    }
    /** Regex patterns from contacts.jsonc#denylistPatterns — same semantics
     *  as desktop (mailx-store db.ts setContactsDenyPatterns): compiled
     *  case-insensitive, tested against the lowercased address, invalid
     *  patterns skipped with a warning. */
    _denyPatterns = [];
    setContactsDenyPatterns(patterns) {
        this._denyPatterns = [];
        for (const p of patterns || []) {
            if (!p)
                continue;
            try {
                this._denyPatterns.push(new RegExp(p, "i"));
            }
            catch (e) {
                console.warn(`[contacts] invalid denylistPattern "${p}": ${e.message}`);
            }
        }
    }
    isDenylisted(emailLower) {
        if (this._denylist.has(emailLower))
            return true;
        return this._denyPatterns.some(re => re.test(emailLower));
    }
    searchContacts(query, limit = 10) {
        query = (query || "").trim();
        if (!query)
            return [];
        const q = `%${query}%`;
        // Over-fetch, then drop denylisted rows in JS — filtering after a
        // hard SQL LIMIT could underfill the result when denylisted rows
        // rank high.
        const rows = this.all(`SELECT name, email, source, use_count as useCount FROM contacts
             WHERE email LIKE ? OR name LIKE ?
             ORDER BY use_count DESC, last_used DESC LIMIT ?`, [q, q, limit + 25]);
        return rows
            .filter((r) => !this.isDenylisted((r.email || "").toLowerCase()))
            .slice(0, limit);
    }
    /** Address-book listing. Same shape as mailx-store/db.ts:listContacts so
     *  the address-book modal renders identically on desktop and Android. */
    listContacts(query, page = 1, pageSize = 100) {
        query = (query || "").trim();
        const hasQuery = !!query;
        const q = `%${query}%`;
        const whereClause = hasQuery ? "WHERE email LIKE ? OR name LIKE ?" : "";
        const params = hasQuery ? [q, q] : [];
        const totalRow = this.get(`SELECT COUNT(*) as c FROM contacts ${whereClause}`, params);
        const offset = (page - 1) * pageSize;
        const rows = this.all(`SELECT name, email, source, google_id, use_count, last_used FROM contacts
             ${whereClause}
             ORDER BY use_count DESC, last_used DESC
             LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
        return {
            items: rows.map((r) => ({
                name: r.name, email: r.email, source: r.source,
                googleId: r.google_id || null,
                useCount: r.use_count, lastUsed: r.last_used,
            })),
            total: totalRow?.c || 0,
            page, pageSize,
        };
    }
    upsertContact(name, email) {
        if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) {
            throw new Error(`Invalid email: ${email}`);
        }
        const now = Date.now();
        const existing = this.get("SELECT id FROM contacts WHERE email = ?", [email]);
        if (existing) {
            this.run("UPDATE contacts SET name = ?, updated_at = ? WHERE email = ?", [name || "", now, email]);
        }
        else {
            this.run("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('manual', ?, ?, 0, 0, ?)", [name || "", email, now]);
        }
    }
    deleteContactLocal(email) {
        this.run("DELETE FROM contacts WHERE email = ?", [email]);
    }
    /** Q49 heuristic: has the user ever sent to `recipientEmail` with a
     *  non-empty Cc? Scans Sent folder(s). Used by compose to auto-expand
     *  the Cc row on reply to a frequent-Cc'd recipient. */
    hasCcHistoryTo(recipientEmail) {
        const email = (recipientEmail || "").trim().toLowerCase();
        if (!email)
            return false;
        try {
            const row = this.get(`
                SELECT 1 FROM messages m
                JOIN folders f ON m.folder_id = f.id
                WHERE f.special_use = 'sent'
                  AND lower(m.to_json) LIKE ?
                  AND m.cc_json IS NOT NULL AND m.cc_json != '[]' AND m.cc_json != ''
                LIMIT 1
            `, [`%"${email}"%`]);
            return !!row;
        }
        catch {
            return false;
        }
    }
    // ── Search ──
    searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
        const offset = (page - 1) * pageSize;
        const parsed = parseSearchQuery(query);
        const allParams = [...parsed.params];
        let where = parsed.conditions.length > 0 ? parsed.conditions.join(" AND ") : "1=0";
        if (accountId && folderId) {
            where += " AND account_id = ? AND folder_id = ?";
            allParams.push(accountId, folderId);
        }
        else if (accountId) {
            where += " AND account_id = ?";
            allParams.push(accountId);
        }
        const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`, allParams);
        const total = countRow?.cnt || 0;
        const rows = this.all(`SELECT * FROM messages WHERE ${where} ORDER BY date DESC LIMIT ? OFFSET ?`, [...allParams, pageSize, offset]);
        return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
    }
    // ── Sync Actions ──
    queueSyncAction(accountId, action, uid, folderId, extra) {
        try {
            this.run(`INSERT OR REPLACE INTO sync_actions (account_id, action, uid, folder_id, target_folder_id, flags_json, raw_message, created_at)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [accountId, action, uid, folderId,
                extra?.targetFolderId || null,
                extra?.flags ? JSON.stringify(extra.flags) : null,
                extra?.rawMessage || null,
                Date.now()]);
        }
        catch { /* UNIQUE constraint */ }
    }
    getPendingSyncActions(accountId) {
        const rows = this.all("SELECT * FROM sync_actions WHERE account_id = ? ORDER BY created_at", [accountId]);
        return rows.map(r => ({
            id: r.id, action: r.action, uid: r.uid, folderId: r.folder_id,
            targetFolderId: r.target_folder_id,
            flags: r.flags_json ? JSON.parse(r.flags_json) : [],
            rawMessage: r.raw_message, attempts: r.attempts,
        }));
    }
    completeSyncAction(id) { this.run("DELETE FROM sync_actions WHERE id = ?", [id]); }
    failSyncAction(id, error) {
        this.run("UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE id = ?", [error, id]);
    }
    /** Delete a queued sync action by (accountId, action, uid) — used by the
     *  send path which tracks queued sends via a unique negative uid rather
     *  than threading the row id through the async send pipeline. */
    completeSyncActionByUid(accountId, action, uid) {
        this.run("DELETE FROM sync_actions WHERE account_id = ? AND action = ? AND uid = ?", [accountId, action, uid]);
    }
    /** Mark a send-queue action failed by uid — same tracking-key rationale. */
    failSyncActionByUid(accountId, action, uid, error) {
        this.run("UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE account_id = ? AND action = ? AND uid = ?", [error, accountId, action, uid]);
    }
    getPendingSyncCount(accountId) {
        const r = this.get("SELECT COUNT(*) as cnt FROM sync_actions WHERE account_id = ?", [accountId]);
        return r?.cnt || 0;
    }
    getTotalPendingSyncCount() {
        const r = this.get("SELECT COUNT(*) as cnt FROM sync_actions");
        return r?.cnt || 0;
    }
    /** Reset the entire database */
    async resetStore() {
        this.run("DELETE FROM sync_actions");
        this.run("DELETE FROM contacts");
        this.run("DELETE FROM messages");
        this.run("DELETE FROM folders");
        this.run("DELETE FROM accounts");
        this.run("DELETE FROM queue");
        await clearIdb();
    }
    // ── Helpers ──
    rowToEnvelope(r) {
        return {
            id: r.id, accountId: r.account_id, folderId: r.folder_id,
            uid: r.uid, messageId: r.message_id || "", inReplyTo: r.in_reply_to || "",
            references: JSON.parse(r.refs || "[]"), date: r.date,
            // NULL for pre-migration rows — the client's `sentDate ?? date`
            // fallback then shows the received date, same as before.
            sentDate: r.sent_date ?? undefined,
            subject: r.subject,
            from: { name: r.from_name, address: r.from_address },
            to: JSON.parse(r.to_json), cc: JSON.parse(r.cc_json),
            flags: JSON.parse(r.flags_json), size: r.size,
            hasAttachments: !!r.has_attachments, preview: r.preview,
            bodyPath: r.body_path || ""
        };
    }
}
//# sourceMappingURL=db.js.map