/**
 * SQLite metadata index for mailx.
 * Stores message headers, folder structure, sync state.
 * Message bodies are NOT here -- they live in the MessageStore backend.
 */

import { DatabaseSync } from "node:sqlite";
import { randomUUID } from "node:crypto";
import * as path from "node:path";
import * as fs from "node:fs";
import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery, SpecialUse } from "@bobfrankston/mailx-types";

/** Addresses that have no business in autocomplete. Standard automated
 *  sender patterns plus name-side hints ("MAILER-DAEMON" etc.). The exact
 *  match list keeps surprise low; the regex catches the long tail of
 *  *-bounces@, no-reply variants, and listserv-style addresses. */
const JUNK_LOCAL_RE = /^(no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|abuse|automated|bounce(s|d)?|list-?(server|admin|owner|manager)?|notification|notifications?|admin@.*automated|root|daemon|nobody|undisclosed)$/i;
const JUNK_LOCAL_SUFFIX_RE = /(-bounces|\+bounces|-noreply|-no-reply|-notifications?|-mailer)$/i;
function isJunkContact(email: string, name: string): boolean {
    const local = email.split("@")[0] || "";
    if (JUNK_LOCAL_RE.test(local)) return true;
    if (JUNK_LOCAL_SUFFIX_RE.test(local)) return true;
    // Bare numeric / hex addresses (rotating IDs from automated systems)
    // — three or fewer chars is too short to be useful regardless.
    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,
        thread_id TEXT,
        date INTEGER NOT NULL,
        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);

    -- Note: idx_messages_thread_id is created by the addColumnIfMissing migration
    -- in the constructor, AFTER thread_id is guaranteed to exist. Including it
    -- here would crash startup on any pre-thread_id DB because exec(SCHEMA) runs
    -- before the column-add migration.

    CREATE TABLE IF NOT EXISTS sent_log (
        message_id TEXT PRIMARY KEY,
        account_id TEXT NOT NULL,
        subject TEXT DEFAULT '',
        recipients TEXT DEFAULT '',
        sent_at INTEGER NOT NULL
    );

    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 'discovered',
        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(source, email, name)
    );

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

    CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
        subject, from_name, from_address, to_text, cc_text, body_text,
        content=messages, content_rowid=id
    );

    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)
    );

    -- Tombstones: messages the user deleted locally. Sync checks this table
    -- before inserting a new row so a server-side delete that hasn't yet
    -- propagated (or a stale server listing during the EXPUNGE race) can't
    -- resurrect a message the user already removed. Keyed by Message-ID
    -- because that's the only identifier stable across UID renumbers,
    -- UIDVALIDITY bumps, and cross-folder moves.
    CREATE TABLE IF NOT EXISTS tombstones (
        account_id TEXT NOT NULL,
        message_id TEXT NOT NULL,
        deleted_at INTEGER NOT NULL,
        subject TEXT DEFAULT '',
        PRIMARY KEY (account_id, message_id)
    );
    CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);

    -- Calendar events: two-way cache of Google Calendar / local events.
    -- uuid = local stable identity (survives provider_id rebinds).
    -- provider_id = Google Calendar event id when known (null for local-only
    -- events that haven't been pushed yet).
    -- deleted = tombstone marker; drainer removes row from server then deletes
    -- the row locally.
    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 INDEX IF NOT EXISTS idx_calendar_events_dirty ON calendar_events(dirty) WHERE dirty = 1;
    -- getCalendarEventByProviderId runs once per event on every Google refresh;
    -- without this index each lookup is a full table scan over calendar_events.
    CREATE INDEX IF NOT EXISTS idx_calendar_events_provider ON calendar_events(account_id, provider_id);

    -- Tasks: two-way cache of Google Tasks / local tasks. Same shape as
    -- calendar_events minus the time range.
    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);
    CREATE INDEX IF NOT EXISTS idx_tasks_dirty ON tasks(dirty) WHERE dirty = 1;
    -- Mirror calendar_events: any provider_id lookup path needs a proper index,
    -- even if today's reconcile does the dedup in memory — prevents a future
    -- refactor from accidentally introducing an O(N) scan.
    CREATE INDEX IF NOT EXISTS idx_tasks_provider ON tasks(account_id, provider_id);

    -- Generic store-sync queue for domains OTHER than messages. Messages
    -- use sync_actions above. This table queues push-to-server actions
    -- for calendar / tasks / contacts / allowlist. Kind identifies the
    -- domain; op is "create" / "update" / "delete"; payload is JSON the
    -- drainer posts to the provider. Target URL isn't stored — the
    -- drainer knows the provider endpoint from kind + payload.
    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)
    );
    CREATE INDEX IF NOT EXISTS idx_store_sync_account ON store_sync(account_id, kind);
    -- UNIQUE(kind, target_uuid, op) covers queries that start with kind; lookups
    -- by target_uuid alone ("is this uuid queued for any op?") would otherwise
    -- table-scan. Cheap; store_sync is tiny and write-heavy.
    CREATE INDEX IF NOT EXISTS idx_store_sync_target_uuid ON store_sync(target_uuid);
    -- Generic per-scope/per-key string store. Used for sync tokens (Google
    -- People nextSyncToken per account, Gmail history-id, calendar sync token,
    -- etc.) and any other small bits of state that need to outlive a process
    -- restart but don't deserve their own table. Keyed by (scope, key).
    CREATE TABLE IF NOT EXISTS kv (
        scope TEXT NOT NULL,
        key TEXT NOT NULL,
        value TEXT,
        updated_at INTEGER NOT NULL,
        PRIMARY KEY(scope, key)
    );
`;

export class MailxDB {
    private db: DatabaseSync;

    constructor(dbDir: string) {
        fs.mkdirSync(dbDir, { recursive: true });
        const dbPath = path.join(dbDir, "mailx.db");
        this.db = new DatabaseSync(dbPath);
        this.db.exec("PRAGMA journal_mode = WAL");
        this.db.exec("PRAGMA foreign_keys = ON");
        this.db.exec(SCHEMA);
        // Idempotent migrations for older databases that predate new columns.
        // SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so we just try the
        // ALTER and catch the "duplicate column" error. Simpler and more robust
        // than probing via PRAGMA table_info (which can behave differently
        // across sqlite drivers).
        this.addColumnIfMissing("messages", "thread_id", "TEXT");
        try {
            this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)");
        } catch { /* already exists */ }
        // provider_id: native server-side id for API-backed providers (Gmail
        // hex id, Outlook Graph id, etc.). Lets fetchOne look up the message
        // directly instead of paginating listMessageIds for every body fetch
        // — a UID-only path costs 2-3 rate-limited API calls per message.
        this.addColumnIfMissing("messages", "provider_id", "TEXT");
        // uuid: stable per-message local identity. Assigned the first time
        // mailx sees the message and never changes — survives server UID
        // renumbers, UIDVALIDITY bumps, and cross-folder moves (the sync
        // rebinds the (folder_id, uid) tuple but keeps the UUID). All UI
        // references SHOULD flow through uuid; (account_id, folder_id, uid)
        // remains the server-binding metadata used only by sync.
        this.addColumnIfMissing("messages", "uuid", "TEXT");
        try {
            this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
        } catch { /* already exists */ }
        // bcc_json: pre-existing DBs predate this column. Without the migration
        // every contacts seed pass throws "no such column: m.bcc_json" and the
        // local autocomplete corpus stays empty.
        this.addColumnIfMissing("messages", "bcc_json", "TEXT DEFAULT '[]'");
        // calendar_events: recurring_event_id carries the Google Calendar
        // series id when the event is an expanded instance of a recurrence.
        // Filters like "hide recurring events" check this column.
        this.addColumnIfMissing("calendar_events", "recurring_event_id", "TEXT");
        this.addColumnIfMissing("calendar_events", "html_link", "TEXT");
        // Backfill UUIDs for any pre-existing rows that were inserted before
        // this column landed. One UPDATE + an id roundtrip per row — cheap
        // at our row counts, runs once per DB upgrade.
        this.backfillUuids();

        // One-shot contacts table reset: the contacts schema's UNIQUE constraint
        // was widened from `(email)` to `(source, email, name)` so the same
        // address can carry multiple distinct (name, source) entries — Bob's
        // wife at bob@example.com and a separate `Bob Smith <bob@example.com>`
        // for work, both legitimate. Old rows with the email-only unique key
        // would block the new inserts. Per user "don't migrate, start fresh":
        // drop the old table, recreate it via SCHEMA, reseed from messages on
        // next sync. Gated by a kv flag so we run exactly once per machine.
        const contactsResetFlag = this.getKv("schema", "contacts_v2");
        if (!contactsResetFlag) {
            try {
                this.db.exec("DROP TABLE IF EXISTS contacts");
                this.db.exec(`
                    CREATE TABLE contacts (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        source TEXT NOT NULL DEFAULT 'discovered',
                        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(source, email, name)
                    );
                    CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
                    CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
                `);
                this.setKv("schema", "contacts_v2", String(Date.now()));
                console.log("  [db] contacts table reset to v2 schema (multi-name-per-email)");
            } catch (e: any) {
                console.error(`  [db] contacts v2 reset failed: ${e.message}`);
            }
        }

        // Post-migration sanity check: verify the columns we actually read in
        // SELECTs exist. If any migration silently failed (stale driver, DB
        // file locked, permission error), later code would throw cryptic
        // "no such column" errors buried deep in a sync run. Fail loud here
        // with a clear "run mailx -rebuild" message. C32 on Linux was exactly
        // this — old mailx-store that predated thread_id/uuid migrations.
        this.verifySchema();
    }

    /** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
     *  runs at startup). The user-facing message names the recovery command. */
    private verifySchema(): void {
        const required: Record<string, string[]> = {
            messages: ["thread_id", "provider_id", "uuid", "bcc_json"],
            calendar_events: ["recurring_event_id", "html_link"],
        };
        for (const [table, cols] of Object.entries(required)) {
            let actual: { name: string }[];
            try {
                actual = this.db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
            } catch (e: any) {
                throw new Error(`[mailx-store] schema check failed for "${table}": ${e.message}. Run 'mailx -rebuild' to rebuild the local store.`);
            }
            const names = new Set(actual.map(r => r.name));
            const missing = cols.filter(c => !names.has(c));
            if (missing.length > 0) {
                throw new Error(`[mailx-store] table "${table}" is missing columns [${missing.join(", ")}] — schema migration did not complete. Run 'mailx -rebuild' to rebuild the local store.`);
            }
        }
    }

    /** Fetch a string from the kv table. Returns null when not set. */
    getKv(scope: string, key: string): string | null {
        const r = this.db.prepare("SELECT value FROM kv WHERE scope = ? AND key = ?").get(scope, key) as { value: string } | undefined;
        return r?.value ?? null;
    }

    /** Upsert a kv row. Pass `null` to delete. */
    setKv(scope: string, key: string, value: string | null): void {
        if (value === null) {
            this.db.prepare("DELETE FROM kv WHERE scope = ? AND key = ?").run(scope, key);
            return;
        }
        this.db.prepare(
            "INSERT INTO kv (scope, key, value, updated_at) VALUES (?, ?, ?, ?) "
            + "ON CONFLICT(scope, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at"
        ).run(scope, key, value, Date.now());
    }

    /** One-time: assign UUIDs to every `messages` row that's missing one.
     *  Runs on every startup but the WHERE clause makes it a no-op after the
     *  first pass. */
    private backfillUuids(): void {
        try {
            const rows = this.db.prepare("SELECT id FROM messages WHERE uuid IS NULL OR uuid = ''").all() as { id: number }[];
            if (rows.length === 0) return;
            console.log(`  [db] backfilling ${rows.length} message UUIDs`);
            const upd = this.db.prepare("UPDATE messages SET uuid = ? WHERE id = ?");
            this.db.exec("BEGIN");
            try {
                for (const r of rows) upd.run(randomUUID().replace(/-/g, ""), r.id);
                this.db.exec("COMMIT");
            } catch (e) {
                this.db.exec("ROLLBACK");
                throw e;
            }
        } catch (e: any) {
            console.error(`  [db] backfillUuids failed: ${e.message}`);
        }
    }

    // ── Sent-log (dedup) ──

    /** Has this Message-ID already been sent? Used to prevent the outbox from
     *  re-sending the same raw file across crash/restart cycles. */
    hasSentMessage(messageId: string): boolean {
        if (!messageId) return false;
        const row = this.db.prepare("SELECT 1 FROM sent_log WHERE message_id = ? LIMIT 1").get(messageId);
        return !!row;
    }

    /** Record a successfully sent message so future attempts are skipped. */
    recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void {
        if (!messageId) return;
        try {
            this.db.prepare(
                "INSERT INTO sent_log (message_id, account_id, subject, recipients, sent_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(message_id) DO NOTHING"
            ).run(messageId, accountId, subject || "", recipients.join(", "), Date.now());
        } catch (e: any) {
            console.error(`  [sent_log] failed to record ${messageId}: ${e.message}`);
        }
    }

    /** Q49 heuristic: has the user ever sent a message to `recipientEmail`
     *  that had a non-empty Cc field? Used by compose to auto-expand the Cc
     *  input when replying to someone who customarily gets Cc'd with others.
     *  Query scans only Sent folders (special_use='sent') and matches the
     *  recipient's address inside `to_json` via LIKE. No special index — the
     *  Sent folder's row count is typically a few thousand at most; acceptable
     *  on the compose-open path. */
    hasCcHistoryTo(recipientEmail: string): boolean {
        const email = (recipientEmail || "").trim().toLowerCase();
        if (!email) return false;
        try {
            const row = this.db.prepare(`
                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
            `).get(`%"${email}"%`);
            return !!row;
        } catch {
            return false;
        }
    }

    /** Same shape as hasCcHistoryTo for the Bcc field. Bcc only appears in the
     *  user's own Sent copy, so it's still a reliable signal that this user
     *  habitually Bccs when writing to this recipient. */
    hasBccHistoryTo(recipientEmail: string): boolean {
        const email = (recipientEmail || "").trim().toLowerCase();
        if (!email) return false;
        try {
            const row = this.db.prepare(`
                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.bcc_json IS NOT NULL AND m.bcc_json != '[]' AND m.bcc_json != ''
                LIMIT 1
            `).get(`%"${email}"%`);
            return !!row;
        } catch {
            return false;
        }
    }

    // ── Tombstones (local-delete record so server echo can't resurrect) ──

    /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
     *  is empty (e.g. provider stripped the header) — without a stable id we
     *  can't check against future sync results anyway. */
    addTombstone(accountId: string, messageId: string, subject: string = ""): void {
        if (!messageId) return;
        try {
            this.db.prepare(
                "INSERT INTO tombstones (account_id, message_id, deleted_at, subject) VALUES (?, ?, ?, ?) ON CONFLICT(account_id, message_id) DO UPDATE SET deleted_at = excluded.deleted_at"
            ).run(accountId, messageId, Date.now(), subject || "");
        } catch (e: any) {
            console.error(`  [tombstones] failed to record ${messageId}: ${e.message}`);
        }
    }

    /** Is this Message-ID tombstoned for this account? */
    hasTombstone(accountId: string, messageId: string): boolean {
        if (!messageId) return false;
        const row = this.db.prepare("SELECT 1 FROM tombstones WHERE account_id = ? AND message_id = ? LIMIT 1").get(accountId, messageId);
        return !!row;
    }

    /** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
     *  re-imports the message as normal. Also lets the user recover from a
     *  mistaken local delete. */
    removeTombstone(accountId: string, messageId: string): void {
        if (!messageId) return;
        try {
            this.db.prepare("DELETE FROM tombstones WHERE account_id = ? AND message_id = ?").run(accountId, messageId);
        } catch (e: any) {
            console.error(`  [tombstones] failed to remove ${messageId}: ${e.message}`);
        }
    }

    /** Age-out tombstones older than the given cutoff. Keeps the table from
     *  growing unboundedly. Default retention is 30 days; caller passes the
     *  actual cutoff in ms since epoch. */
    pruneTombstones(olderThanMs: number): number {
        try {
            const res = this.db.prepare("DELETE FROM tombstones WHERE deleted_at < ?").run(olderThanMs);
            return Number(res.changes || 0);
        } catch (e: any) {
            console.error(`  [tombstones] prune failed: ${e.message}`);
            return 0;
        }
    }

    // ── Calendar events (two-way cache) ──

    upsertCalendarEvent(ev: {
        uuid?: string; accountId: string; providerId?: string; calendarId?: string;
        title: string; startMs: number; endMs: number; allDay?: boolean;
        location?: string; notes?: string; etag?: string; dirty?: boolean;
        recurringEventId?: string; htmlLink?: string;
    }): string {
        const uuid = ev.uuid || randomUUID().replace(/-/g, "");
        this.db.prepare(`
            INSERT INTO calendar_events
                (uuid, account_id, provider_id, calendar_id, title, start_ms, end_ms,
                 all_day, location, notes, etag, last_synced, dirty, deleted, updated_at,
                 recurring_event_id, html_link)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
            ON CONFLICT(uuid) DO UPDATE SET
                account_id=excluded.account_id, provider_id=excluded.provider_id,
                calendar_id=excluded.calendar_id, title=excluded.title,
                start_ms=excluded.start_ms, end_ms=excluded.end_ms,
                all_day=excluded.all_day, location=excluded.location,
                notes=excluded.notes, etag=excluded.etag,
                last_synced=excluded.last_synced, dirty=excluded.dirty,
                updated_at=excluded.updated_at,
                recurring_event_id=excluded.recurring_event_id,
                html_link=excluded.html_link
        `).run(
            uuid, ev.accountId, ev.providerId || null, ev.calendarId || "primary",
            ev.title, ev.startMs, ev.endMs, ev.allDay ? 1 : 0,
            ev.location || "", ev.notes || "", ev.etag || null,
            ev.dirty ? 0 : Date.now(), ev.dirty ? 1 : 0, Date.now(),
            ev.recurringEventId || null, ev.htmlLink || null
        );
        return uuid;
    }

    getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[] {
        const rows = this.db.prepare(`
            SELECT * FROM calendar_events
            WHERE account_id = ? AND deleted = 0 AND start_ms >= ? AND start_ms < ?
            ORDER BY start_ms ASC
        `).all(accountId, fromMs, toMs) as any[];
        return rows.map(this.calendarRowToObject);
    }

    /** Lookup by uuid only — used by patch/delete paths that don't have an
     *  accountId context. Returns the row even when it's soft-deleted. */
    getCalendarEventByUuid(uuid: string): any | null {
        const r = this.db.prepare("SELECT * FROM calendar_events WHERE uuid = ?").get(uuid) as any;
        return r ? this.calendarRowToObject(r) : null;
    }

    getTaskByUuid(uuid: string): any | null {
        const r = this.db.prepare("SELECT * FROM tasks WHERE uuid = ?").get(uuid) as any;
        return r ? this.taskRowToObject(r) : null;
    }

    getDirtyCalendarEvents(accountId: string): any[] {
        const rows = this.db.prepare(`
            SELECT * FROM calendar_events WHERE account_id = ? AND (dirty = 1 OR deleted = 1)
        `).all(accountId) as any[];
        return rows.map(this.calendarRowToObject);
    }

    private calendarRowToObject(r: any): any {
        return {
            uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
            calendarId: r.calendar_id, title: r.title, startMs: r.start_ms,
            endMs: r.end_ms, allDay: !!r.all_day, location: r.location, notes: r.notes,
            etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
            recurringEventId: r.recurring_event_id || null,
            htmlLink: r.html_link || null,
        };
    }

    /** Find a calendar event by its Google Calendar event id (provider_id).
     *  Global lookup — not window-scoped — so repeat pulls dedup cleanly. */
    getCalendarEventByProviderId(accountId: string, providerId: string): any | null {
        const r = this.db.prepare(
            "SELECT * FROM calendar_events WHERE account_id = ? AND provider_id = ?"
        ).get(accountId, providerId) as any;
        return r ? this.calendarRowToObject(r) : null;
    }

    markCalendarEventClean(uuid: string, providerId: string, etag: string): void {
        this.db.prepare(`
            UPDATE calendar_events SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?
        `).run(providerId, etag, Date.now(), uuid);
    }

    deleteCalendarEventLocal(uuid: string): void {
        this.db.prepare("UPDATE calendar_events SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
    }

    purgeCalendarEvent(uuid: string): void {
        this.db.prepare("DELETE FROM calendar_events WHERE uuid=?").run(uuid);
    }

    // ── Tasks (two-way cache) ──

    upsertTask(t: {
        uuid?: string; accountId: string; providerId?: string; listId?: string;
        title: string; notes?: string; dueMs?: number; completedMs?: number;
        etag?: string; dirty?: boolean;
    }): string {
        const uuid = t.uuid || randomUUID().replace(/-/g, "");
        this.db.prepare(`
            INSERT INTO tasks
                (uuid, account_id, provider_id, list_id, title, notes, due_ms, completed_ms,
                 etag, last_synced, dirty, deleted, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)
            ON CONFLICT(uuid) DO UPDATE SET
                account_id=excluded.account_id, provider_id=excluded.provider_id,
                list_id=excluded.list_id, title=excluded.title, notes=excluded.notes,
                due_ms=excluded.due_ms, completed_ms=excluded.completed_ms,
                etag=excluded.etag, last_synced=excluded.last_synced,
                dirty=excluded.dirty, updated_at=excluded.updated_at
        `).run(
            uuid, t.accountId, t.providerId || null, t.listId || "@default",
            t.title, t.notes || "", t.dueMs || null, t.completedMs || null,
            t.etag || null, t.dirty ? 0 : Date.now(), t.dirty ? 1 : 0, Date.now()
        );
        return uuid;
    }

    getTasks(accountId: string, includeCompleted = false): any[] {
        const where = includeCompleted
            ? "account_id = ? AND deleted = 0"
            : "account_id = ? AND deleted = 0 AND completed_ms IS NULL";
        const rows = this.db.prepare(`SELECT * FROM tasks WHERE ${where} ORDER BY COALESCE(due_ms, updated_at) ASC`).all(accountId) as any[];
        return rows.map(this.taskRowToObject);
    }

    getDirtyTasks(accountId: string): any[] {
        const rows = this.db.prepare(`SELECT * FROM tasks WHERE account_id = ? AND (dirty = 1 OR deleted = 1)`).all(accountId) as any[];
        return rows.map(this.taskRowToObject);
    }

    private taskRowToObject(r: any): any {
        return {
            uuid: r.uuid, accountId: r.account_id, providerId: r.provider_id,
            listId: r.list_id, title: r.title, notes: r.notes, dueMs: r.due_ms,
            completedMs: r.completed_ms, etag: r.etag, lastSynced: r.last_synced,
            dirty: !!r.dirty, deleted: !!r.deleted,
        };
    }

    markTaskClean(uuid: string, providerId: string, etag: string): void {
        this.db.prepare(`UPDATE tasks SET dirty=0, provider_id=?, etag=?, last_synced=? WHERE uuid=?`)
            .run(providerId, etag, Date.now(), uuid);
    }

    deleteTaskLocal(uuid: string): void {
        this.db.prepare("UPDATE tasks SET deleted=1, dirty=1, updated_at=? WHERE uuid=?").run(Date.now(), uuid);
    }

    purgeTask(uuid: string): void {
        this.db.prepare("DELETE FROM tasks WHERE uuid=?").run(uuid);
    }

    // ── Contacts two-way: existing upsertContact / deleteContact handle the
    //     local side; the service layer adds store_sync push-queue entries.
    //     (No extra methods needed here — upsertContact/deleteContact at the
    //     regular contact-management section are the two-way cache's local
    //     writers.)

    /** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
    deleteContactLocal(email: string): void { this.deleteContact(email); }

    // ── Store-sync queue (calendar / tasks / contacts / allowlist) ──

    enqueueStoreSync(kind: string, op: string, accountId: string, targetUuid: string, payload: any): void {
        try {
            this.db.prepare(`
                INSERT OR REPLACE INTO store_sync (kind, op, account_id, target_uuid, payload, created_at)
                VALUES (?, ?, ?, ?, ?, ?)
            `).run(kind, op, accountId, targetUuid, JSON.stringify(payload), Date.now());
        } catch (e: any) {
            console.error(`  [store_sync] enqueue ${kind}/${op}/${targetUuid} failed: ${e.message}`);
        }
    }

    getStoreSyncQueue(kind?: string, accountId?: string): any[] {
        let sql = "SELECT * FROM store_sync";
        const params: any[] = [];
        const wh: string[] = [];
        if (kind) { wh.push("kind = ?"); params.push(kind); }
        if (accountId) { wh.push("account_id = ?"); params.push(accountId); }
        if (wh.length) sql += " WHERE " + wh.join(" AND ");
        sql += " ORDER BY created_at ASC";
        const rows = this.db.prepare(sql).all(...params) as any[];
        return rows.map(r => ({
            id: r.id, kind: r.kind, op: r.op, accountId: r.account_id,
            targetUuid: r.target_uuid,
            payload: r.payload ? JSON.parse(r.payload) : null,
            attempts: r.attempts, lastError: r.last_error,
        }));
    }

    completeStoreSync(id: number): void {
        this.db.prepare("DELETE FROM store_sync WHERE id = ?").run(id);
    }

    failStoreSync(id: number, error: string): void {
        this.db.prepare("UPDATE store_sync SET attempts = attempts + 1, last_error = ? WHERE id = ?").run(error, id);
    }

    /** Idempotently add a column to a table if it's missing. */
    private addColumnIfMissing(table: string, column: string, sqlType: string): void {
        try {
            this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${sqlType}`);
            console.log(`  [db] added column ${table}.${column}`);
        } catch (e: any) {
            const msg = String(e?.message || e);
            // "duplicate column name" is the expected case when column already exists
            if (!/duplicate column/i.test(msg)) {
                console.error(`  [db] migration ${table}.${column} failed: ${msg}`);
            }
        }
    }

    /** Compute a thread id for an incoming message. Strategy:
     *  1. If any ancestor (in_reply_to or references) is already present in
     *     messages with a thread_id, reuse it — this handles the case where
     *     replies arrive before / after the root.
     *  2. Otherwise use the oldest ref (first entry in References), or
     *     in_reply_to, or the message's own messageId as the thread root. */
    private computeThreadId(accountId: string, messageId: string, inReplyTo: string, references: string[]): string {
        const candidates: string[] = [];
        if (references && references.length) candidates.push(...references);
        if (inReplyTo && !candidates.includes(inReplyTo)) candidates.push(inReplyTo);
        if (messageId && !candidates.includes(messageId)) candidates.push(messageId);

        // Look for an existing thread anchored on any of the ancestors
        for (const mid of candidates) {
            if (!mid) continue;
            const row = this.db.prepare(
                "SELECT thread_id FROM messages WHERE account_id = ? AND message_id = ? AND thread_id IS NOT NULL LIMIT 1"
            ).get(accountId, mid) as { thread_id: string } | undefined;
            if (row?.thread_id) return row.thread_id;
        }
        // No existing thread — seed from the oldest ref, falling back to
        // in_reply_to, then messageId
        return (references && references[0]) || inReplyTo || messageId || `orphan-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    }

    /** Get all messages in a thread (across folders) for a given account. */
    getThreadMessages(accountId: string, threadId: string): MessageEnvelope[] {
        if (!threadId) return [];
        const rows = this.db.prepare(
            `SELECT * FROM messages WHERE account_id = ? AND thread_id = ? ORDER BY date ASC`
        ).all(accountId, threadId) as any[];
        return rows.map(r => ({
            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 || "[]"),
            threadId: r.thread_id || undefined,
            date: r.date,
            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 || undefined,
        }));
    }

    close(): void {
        this.db.close();
    }

    // ── Accounts ──

    upsertAccount(id: string, name: string, email: string, configJson: string): void {
        this.db.prepare(`
            INSERT INTO accounts (id, name, email, config_json)
            VALUES (?, ?, ?, ?)
            ON CONFLICT(id) DO UPDATE SET name=?, email=?, config_json=?
        `).run(id, name, email, configJson, name, email, configJson);
    }

    getAccounts(): { id: string; name: string; email: string; lastSync: number }[] {
        return this.db.prepare("SELECT id, name, email, last_sync as lastSync FROM accounts").all() as any[];
    }

    getAccountConfigs(): { id: string; name: string; email: string; configJson: string }[] {
        return this.db.prepare("SELECT id, name, email, config_json as configJson FROM accounts").all() as any[];
    }

    updateLastSync(accountId: string, timestamp: number): void {
        this.db.prepare("UPDATE accounts SET last_sync = ? WHERE id = ?").run(timestamp, accountId);
    }

    // ── Folders ──

    upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number {
        const existing = this.db.prepare(
            "SELECT id FROM folders WHERE account_id = ? AND path = ?"
        ).get(accountId, folderPath) as { id: number } | undefined;

        if (existing) {
            this.db.prepare(
                "UPDATE folders SET name = ?, special_use = ?, delimiter = ? WHERE id = ?"
            ).run(name, specialUse, delimiter, existing.id);
            return existing.id;
        }

        const result = this.db.prepare(
            "INSERT INTO folders (account_id, path, name, special_use, delimiter) VALUES (?, ?, ?, ?, ?)"
        ).run(accountId, folderPath, name, specialUse, delimiter);
        return Number(result.lastInsertRowid);
    }

    getFolders(accountId: string): Folder[] {
        const rows = this.db.prepare(
            "SELECT * FROM folders WHERE account_id = ? ORDER BY path"
        ).all(accountId) as any[];

        const folders: Folder[] = 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: [] as Folder[]
        }));

        // Sub-folder inheritance: a folder under Drafts/Sent/Trash/Junk/Archive
        // inherits the parent's special role for UI purposes (column layout,
        // open-in-compose, etc.). INBOX is intentionally excluded — its sub-
        // folders are typically filtered mail and inheriting "inbox" would
        // inflate All Inboxes. findFolder() still resolves to the canonical
        // folder because rows are sorted by path and the parent sorts before
        // its children.
        const INHERITABLE = new Set(["sent", "drafts", "trash", "junk", "archive"]);
        const roleByPath = new Map<string, SpecialUse>();
        for (const f of folders) {
            if (f.specialUse && INHERITABLE.has(f.specialUse)) {
                roleByPath.set(f.path, f.specialUse);
            }
        }
        for (const f of folders) {
            if (f.specialUse) continue;
            const delim = f.delimiter || "/";
            const parts = f.path.split(delim);
            while (parts.length > 1) {
                parts.pop();
                const role = roleByPath.get(parts.join(delim));
                if (role) { f.specialUse = role; break; }
            }
        }

        return folders;
    }

    deleteFolder(folderId: number): void {
        this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
        this.db.prepare("DELETE FROM folders WHERE id = ?").run(folderId);
    }

    markFolderRead(folderId: number): void {
        this.db.prepare(
            `UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]') WHERE folder_id = ? AND flags_json NOT LIKE '%\\\\Seen%'`
        ).run(folderId);
        this.recalcFolderCounts(folderId);
    }

    deleteAllMessages(accountId: string, folderId: number): void {
        this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ?").run(accountId, folderId);
        this.recalcFolderCounts(folderId);
    }

    updateFolderCounts(folderId: number, total: number, unread: number): void {
        this.db.prepare(
            "UPDATE folders SET total_count = ?, unread_count = ? WHERE id = ?"
        ).run(total, unread, folderId);
    }

    updateFolderSync(folderId: number, uidvalidity: number, highestModseq: string): void {
        this.db.prepare(
            "UPDATE folders SET uidvalidity = ?, highest_modseq = ? WHERE id = ?"
        ).run(uidvalidity, highestModseq, folderId);
    }

    getFolderSync(folderId: number): { uidvalidity: number; highestModseq: string } {
        const row = this.db.prepare(
            "SELECT uidvalidity, highest_modseq as highestModseq FROM folders WHERE id = ?"
        ).get(folderId) as any;
        return row || { uidvalidity: 0, highestModseq: "0" };
    }

    // ── Messages ──

    upsertMessage(msg: {
        accountId: string;
        folderId: number;
        uid: number;
        messageId: string;
        inReplyTo: string;
        references: string[];
        date: number;
        subject: string;
        from: EmailAddress;
        to: EmailAddress[];
        cc: EmailAddress[];
        flags: string[];
        size: number;
        hasAttachments: boolean;
        preview: string;
        bodyPath: string;
        providerId?: string;
    }): number {
        const existing = this.db.prepare(
            "SELECT id, provider_id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?"
        ).get(msg.accountId, msg.folderId, msg.uid) as { id: number; provider_id: string | null } | undefined;

        if (existing) {
            // Backfill provider_id on existing rows that predate this column —
            // critical for body fetch to bypass listMessageIds pagination.
            if (msg.providerId && !existing.provider_id) {
                this.db.prepare("UPDATE messages SET provider_id = ? WHERE id = ?").run(msg.providerId, existing.id);
            }
            // Only overwrite body_path / preview when the caller actually has a
            // body. Metadata-only syncs (Gmail API storeApiMessages, IMAP
            // header-only fetches) pass bodyPath: "" and would otherwise wipe
            // the path that prefetch just wrote, causing prefetch to re-download
            // every message every cycle.
            if (msg.bodyPath) {
                this.db.prepare(`
                    UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
                    WHERE id = ?
                `).run(JSON.stringify(msg.flags), msg.preview, msg.bodyPath, Date.now(), existing.id);
            } else {
                this.db.prepare(`
                    UPDATE messages SET flags_json = ?, cached_at = ?
                    WHERE id = ?
                `).run(JSON.stringify(msg.flags), Date.now(), existing.id);
            }
            return existing.id;
        }

        // Move-detection: if this Message-ID already exists for this account
        // in a DIFFERENT folder, treat it as a server-side move rather than a
        // new arrival. Rebind that row to (folder_id, uid) — keep the UUID,
        // body_path, flags, all the local state. Saves a body re-fetch and
        // preserves any local references (in_reply_to, dally entries, undo
        // stacks) that point at the UUID. Only kicks in when messageId is
        // present (servers usually include it; if not we fall through to a
        // fresh insert which mints a new UUID).
        if (msg.messageId) {
            const moved = this.db.prepare(
                "SELECT id, folder_id, uid FROM messages WHERE account_id = ? AND message_id = ? LIMIT 1"
            ).get(msg.accountId, msg.messageId) as { id: number; folder_id: number; uid: number } | undefined;
            if (moved) {
                console.log(`  [move-detect] ${msg.accountId} ${msg.messageId}: rebinding row ${moved.id} (folder ${moved.folder_id}/uid ${moved.uid} → folder ${msg.folderId}/uid ${msg.uid})`);
                // Update folder_id + uid; preserve uuid, body_path, flags
                // (server flags will catch up on the next full sync).
                this.db.prepare(
                    "UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?"
                ).run(msg.folderId, msg.uid, Date.now(), moved.id);
                return moved.id;
            }
        }

        const toText = msg.to.map(a => `${a.name} ${a.address}`).join(" ");
        const ccText = msg.cc.map(a => `${a.name} ${a.address}`).join(" ");

        // Thread id = oldest ancestor in the reference chain, or the in-reply-to
        // parent, or the message's own Message-ID as a fallback. We also check
        // whether an existing row already has a thread_id for any of the refs,
        // so late-arriving replies latch onto the same thread.
        const threadId = this.computeThreadId(msg.accountId, msg.messageId, msg.inReplyTo, msg.references);

        // Mint a per-message local identity UUID at first-sight. Stable for
        // the life of the row — survives server UID renumbers, folder moves
        // (sync rebinds folder_id/uid but keeps uuid), UIDVALIDITY bumps.
        const uuid = randomUUID().replace(/-/g, "");

        const result = this.db.prepare(`
            INSERT INTO messages (
                account_id, folder_id, uid, uuid, message_id, in_reply_to, refs, thread_id,
                date, subject, from_address, from_name, to_json, cc_json,
                flags_json, size, has_attachments, preview, body_path, cached_at, provider_id
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        `).run(
            msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId,
            msg.inReplyTo, JSON.stringify(msg.references), threadId,
            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(), msg.providerId || null
        );

        const rowId = Number(result.lastInsertRowid);

        // Index for full-text search
        try {
            this.db.prepare(
                "INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)"
            ).run(rowId, msg.subject, msg.from.name, msg.from.address, toText, ccText, msg.preview);
        } catch { /* FTS insert may fail on rebuild, non-fatal */ }

        return rowId;
    }

    getMessages(query: MessageQuery): PagedResult<MessageEnvelope> {
        const page = query.page || 1;
        const pageSize = query.pageSize || 50;
        const offset = (page - 1) * pageSize;
        const sort = query.sort || "date";
        const sortDir = query.sortDir || "desc";

        const sortCol = sort === "from" ? "from_name" : sort === "subject" ? "subject" : "date";

        let where = "account_id = ? AND folder_id = ?";
        const params: (string | number)[] = [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);
        }

        if (query.flaggedOnly) {
            // flags_json is a JSON array like ["\\Seen","\\Flagged"]. A plain
            // LIKE on the serialized form is sufficient to find rows with the
            // \Flagged flag without decoding every row.
            where += " AND flags_json LIKE '%\\\\Flagged%'";
        }

        const total = (this.db.prepare(
            `SELECT COUNT(*) as cnt FROM messages WHERE ${where}`
        ).get(...params) as any).cnt;

        // LEFT JOIN sync_actions so each row carries a `pending` flag —
        // true when the user has a queued local action (move/flag/delete)
        // not yet acknowledged by the server. UI renders these in pink so
        // local-only state is visible (Slice C of S1). Negative UIDs also
        // count as pending: that's the convention for optimistic local
        // inserts (e.g. Sent rows written the moment the user hits Send,
        // before the real APPENDUID comes back from the server).
        const rows = this.db.prepare(
            `SELECT m.*, (
                EXISTS(
                    SELECT 1 FROM sync_actions sa
                    WHERE sa.account_id = m.account_id AND sa.uid = m.uid
                ) OR m.uid < 0
            ) AS pending
            FROM messages m WHERE ${where.replace(/\b(account_id|folder_id|uid|date|subject|from_name|from_address|flags_json)\b/g, "m.$1")}
            ORDER BY m.${sortCol} ${sortDir} LIMIT ? OFFSET ?`
        ).all(...params, pageSize, offset) as any[];

        const items: MessageEnvelope[] = rows.map(r => ({
            id: r.id,
            accountId: r.account_id,
            folderId: r.folder_id,
            uid: r.uid,
            uuid: r.uuid || "",
            messageId: r.message_id || "",
            inReplyTo: r.in_reply_to || "",
            references: JSON.parse(r.refs || "[]"),
            threadId: r.thread_id || undefined,
            date: r.date,
            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 || "",
            pending: !!r.pending,
        } as any));

        return { items, total, page, pageSize };
    }

    /** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
    getUnifiedInbox(page = 1, pageSize = 50): PagedResult<MessageEnvelope> {
        const offset = (page - 1) * pageSize;
        // Find all inbox folder IDs
        const inboxRows = this.db.prepare(
            "SELECT id FROM folders WHERE special_use = 'inbox'"
        ).all() as any[];
        if (inboxRows.length === 0) return { items: [], total: 0, page, pageSize };

        const placeholders = inboxRows.map(() => "?").join(",");
        const folderIds = inboxRows.map((r: any) => r.id);

        const total = (this.db.prepare(
            `SELECT COUNT(*) as cnt FROM messages WHERE folder_id IN (${placeholders})`
        ).get(...folderIds) as any).cnt;

        const rows = this.db.prepare(
            `SELECT m.*, EXISTS(
                SELECT 1 FROM sync_actions sa
                WHERE sa.account_id = m.account_id AND sa.uid = m.uid
            ) AS pending,
            (SELECT COUNT(DISTINCT account_id) FROM messages m2
             WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
            FROM messages m WHERE m.folder_id IN (${placeholders})
            ORDER BY m.date DESC LIMIT ? OFFSET ?`
        ).all(...folderIds, pageSize, offset) as any[];

        const items: MessageEnvelope[] = rows.map(r => ({
            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 || "[]"),
            threadId: r.thread_id || undefined,
            date: r.date,
            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 || "",
            pending: !!r.pending,
            // >=2 means the same message-id exists under another account in
            // the local DB (delivered to both accounts, or a mailing-list
            // Bcc). The unified-inbox UI shows a small ⇆ badge on these
            // rows so the user knows "this is a copy of the same message".
            dupeCount: (r.dupeCount as number) | 0,
        } as any));

        return { items, total, page, pageSize };
    }

    /** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
     *  identity) and `bodyPath` (authoritative on-disk location) in addition
     *  to the server-binding metadata. */
    private rowToEnvelope(r: any): MessageEnvelope {
        return {
            id: r.id,
            accountId: r.account_id,
            folderId: r.folder_id,
            uid: r.uid,
            uuid: r.uuid || "",
            messageId: r.message_id || "",
            inReplyTo: r.in_reply_to || "",
            references: JSON.parse(r.refs || "[]"),
            threadId: r.thread_id || undefined,
            date: r.date,
            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 || "",
            providerId: r.provider_id || undefined,
        } as MessageEnvelope;
    }

    getMessageByUid(accountId: string, uid: number, folderId?: number): MessageEnvelope {
        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.db.prepare(sql).get(...params) as any;
        if (!r) return null as any;
        return this.rowToEnvelope(r);
    }

    /** Look up a message by its stable local UUID. Returned envelope includes
     *  the current (folder_id, uid) — these may have changed since the UUID
     *  was minted (folder move or server UID renumber) but the UUID itself
     *  is stable. Use this as the identity in any long-lived reference
     *  (compose in-reply-to, dally, undo stacks). */
    getMessageByUuid(uuid: string): MessageEnvelope {
        if (!uuid) return null as any;
        const r = this.db.prepare("SELECT * FROM messages WHERE uuid = ?").get(uuid) as any;
        if (!r) return null as any;
        return this.rowToEnvelope(r);
    }

    getMessageBodyPath(accountId: string, uid: number): string {
        const r = this.db.prepare(
            "SELECT body_path FROM messages WHERE account_id = ? AND uid = ?"
        ).get(accountId, uid) as any;
        return r?.body_path || "";
    }

    updateMessageFlags(accountId: string, uid: number, flags: string[]): void {
        this.db.prepare(
            "UPDATE messages SET flags_json = ? WHERE account_id = ? AND uid = ?"
        ).run(JSON.stringify(flags), accountId, uid);
    }

    updateMessageFolder(accountId: string, uid: number, targetFolderId: number): void {
        // Idempotency: if a row already exists at (account, target_folder, uid)
        // — common with Gmail's hash-synthesized UIDs across labels, or any
        // case where the move was already partially applied — the UPDATE
        // would fail the (acct, folder, uid) unique constraint. Treat it as
        // a no-op: drop the source row, the message is already where the
        // user wants it. Previously surfaced as "Mark-as-spam failed: UNIQUE
        // constraint failed" — bad UX for what is logically already done.
        const existingTarget = this.db.prepare(
            "SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?"
        ).get(accountId, targetFolderId, uid);
        if (existingTarget) {
            this.db.prepare(
                "DELETE FROM messages WHERE account_id = ? AND uid = ? AND folder_id != ?"
            ).run(accountId, uid, targetFolderId);
            return;
        }
        this.db.prepare(
            "UPDATE messages SET folder_id = ? WHERE account_id = ? AND uid = ?"
        ).run(targetFolderId, accountId, uid);
    }

    updateBodyPath(accountId: string, uid: number, bodyPath: string): void {
        this.db.prepare(
            "UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?"
        ).run(bodyPath, accountId, uid);
    }

    /** Get messages without cached bodies (for background prefetch) */
    getMessagesWithoutBody(accountId: string, limit = 50): { uid: number; folderId: number }[] {
        return this.db.prepare(
            "SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path = '') ORDER BY date DESC LIMIT ?"
        ).all(accountId, limit) as any[];
    }

    getHighestUid(accountId: string, folderId: number): number {
        const r = this.db.prepare(
            "SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?"
        ).get(accountId, folderId) as any;
        return r?.maxUid || 0;
    }

    getOldestDate(accountId: string, folderId: number): number {
        const r = this.db.prepare(
            "SELECT MIN(date) as minDate FROM messages WHERE account_id = ? AND folder_id = ?"
        ).get(accountId, folderId) as any;
        return r?.minDate || 0;
    }

    getMessageCount(accountId: string, folderId: number): number {
        const r = this.db.prepare(
            "SELECT count(*) as cnt FROM messages WHERE account_id = ? AND folder_id = ?"
        ).get(accountId, folderId) as any;
        return r?.cnt || 0;
    }

    /** Get all UIDs for a folder */
    getUidsForFolder(accountId: string, folderId: number): number[] {
        const rows = this.db.prepare(
            "SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?"
        ).all(accountId, folderId) as any[];
        return rows.map(r => r.uid);
    }

    /** Delete a message by account + UID */
    deleteMessage(accountId: string, uid: number): void {
        // Get folderId before deleting so we can update counts
        const msg = this.db.prepare(
            "SELECT folder_id FROM messages WHERE account_id = ? AND uid = ?"
        ).get(accountId, uid) as any;
        this.db.prepare(
            "DELETE FROM messages WHERE account_id = ? AND uid = ?"
        ).run(accountId, uid);
        // Refresh folder counts
        if (msg) this.recalcFolderCounts(msg.folder_id);
    }

    /** Recalculate folder total/unread counts from actual messages */
    recalcFolderCounts(folderId: number): void {
        const counts = this.db.prepare(
            `SELECT COUNT(*) as total,
                    SUM(CASE WHEN flags_json NOT LIKE '%\\\\Seen%' THEN 1 ELSE 0 END) as unread
             FROM messages WHERE folder_id = ?`
        ).get(folderId) as any;
        this.updateFolderCounts(folderId, counts?.total || 0, counts?.unread || 0);
    }

    /** Bulk insert within a transaction for sync performance */
    beginTransaction(): void { this.db.exec("BEGIN"); }
    commitTransaction(): void { this.db.exec("COMMIT"); }
    rollbackTransaction(): void { this.db.exec("ROLLBACK"); }

    // ── Contacts ──

    /** Record an address used in sent mail */
    recordSentAddress(name: string, email: string): void {
        // Don't pollute the contacts table with non-addresses.
        if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) return;
        const lower = email.toLowerCase();
        if (this.isAddressDenylisted(lower)) return;
        const now = Date.now();
        // discovered tier holds one row per email — bump if present, else
        // insert. Doesn't touch preferred or google rows for the same email;
        // those are independent address-book entries the user/Google curates.
        const existing = this.db.prepare(
            "SELECT id, name FROM contacts WHERE source = 'discovered' AND lower(email) = ?"
        ).get(lower) as { id: number; name: string } | undefined;
        if (existing) {
            this.db.prepare(
                "UPDATE contacts SET name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END, last_used = ?, use_count = use_count + 1, updated_at = ? WHERE id = ?"
            ).run(name, name, now, now, existing.id);
        } else {
            this.db.prepare(
                "INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, 1, ?)"
            ).run(name || "", email, now, now);
        }
        this.notifyContactsChanged();
    }

    /** True if `email` (lowercased) appears in the active denylist. Cached
     *  in-memory; refreshed on contacts.jsonc reload via setContactsDenylist. */
    private _denylist: Set<string> = new Set();
    isAddressDenylisted(emailLower: string): boolean {
        return this._denylist.has(emailLower);
    }
    setContactsDenylist(emails: string[]): void {
        this._denylist = new Set(emails.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
    }

    /** Callback fired when local-DB contacts mutations happen (sends adding
     *  to discovered, corpus seeder finding new addresses). The service
     *  registers a debounced cloud flush here so the GDrive copy stays in
     *  sync. NOT fired from applyContactsConfig — that's the inbound path
     *  and would create a write loop. */
    private _onContactsChanged?: () => void;
    setOnContactsChanged(cb: () => void): void {
        this._onContactsChanged = cb;
    }
    private notifyContactsChanged(): void {
        try { this._onContactsChanged?.(); } catch { /* ignore */ }
    }

    /** Seed `discovered`-tier contacts from every address that appears in
     *  any cached message — From / To / Cc / Bcc across all folders. One row
     *  per email; first non-empty name observed wins. Sent-folder rows skip
     *  the From (it's us). Junk addresses (noreply, mailer-daemon, *-bounces)
     *  and denylisted addresses are dropped at seed time so they never enter
     *  autocomplete.
     *
     *  Discovered is a single tier; sub-distinctions like sent-vs-received
     *  collapse here because the user-facing UI shows them as one "discovered"
     *  source. Recency-weighted use_count differentiates within the tier. */
    seedContactsFromMessages(): number {
        const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
        const now = Date.now();
        const agg = new Map<string, { name: string; cnt: number; last: number }>();
        const bump = (name: string, address: string, date: number) => {
            const email = (address || "").trim().toLowerCase();
            if (!email || !VALID.test(email)) return;
            if (isJunkContact(email, name)) return;
            if (this.isAddressDenylisted(email)) return;
            const e = agg.get(email);
            if (e) {
                e.cnt++;
                if (date > e.last) e.last = date;
                if (!e.name && name) e.name = name;
            } else {
                agg.set(email, { name: name || "", cnt: 1, last: date || 0 });
            }
        };

        // Sent folder: recipients only (skip the user's own From address).
        const sentRows = this.db.prepare(
            `SELECT m.to_json, m.cc_json, m.bcc_json, m.date
             FROM messages m
             JOIN folders f ON m.folder_id = f.id
             WHERE f.special_use = 'sent'`
        ).all() as { to_json: string; cc_json: string; bcc_json: string; date: number }[];
        for (const r of sentRows) {
            const date = r.date || 0;
            for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
                if (!field) continue;
                let parsed: any[];
                try { parsed = JSON.parse(field); } catch { continue; }
                if (!Array.isArray(parsed)) continue;
                for (const a of parsed) {
                    if (!a) continue;
                    bump(a.name || "", a.address || a.email || "", date);
                }
            }
        }

        // Other folders: From + recipients.
        const recvRows = this.db.prepare(
            `SELECT m.from_name, m.from_address, m.to_json, m.cc_json, m.bcc_json, m.date
             FROM messages m
             LEFT JOIN folders f ON m.folder_id = f.id
             WHERE f.special_use IS NULL OR f.special_use != 'sent'`
        ).all() as { from_name: string; from_address: string; to_json: string; cc_json: string; bcc_json: string; date: number }[];
        for (const r of recvRows) {
            const date = r.date || 0;
            bump(r.from_name, r.from_address, date);
            for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
                if (!field) continue;
                let parsed: any[];
                try { parsed = JSON.parse(field); } catch { continue; }
                if (!Array.isArray(parsed)) continue;
                for (const a of parsed) {
                    if (!a) continue;
                    bump(a.name || "", a.address || a.email || "", date);
                }
            }
        }

        let added = 0;
        let bumped = 0;
        const insStmt = this.db.prepare(
            "INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, ?, ?)"
        );
        const updStmt = this.db.prepare(
            `UPDATE contacts SET use_count = ?,
                                 last_used = max(last_used, ?),
                                 name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
                                 updated_at = ?
             WHERE id = ?`
        );
        for (const [email, info] of agg) {
            const existing = this.db.prepare(
                "SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?"
            ).get(email) as { id: number } | undefined;
            if (!existing) {
                insStmt.run(info.name, email, info.last, info.cnt, now);
                added++;
            } else {
                updStmt.run(info.cnt, info.last, info.name, info.name, now, existing.id);
                bumped++;
            }
        }
        if (added > 0 || bumped > 0) {
            console.log(`  [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
            this.notifyContactsChanged();
        }
        return added;
    }

    /** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
     *  with the entries in `preferred[]`, merges `discovered[]` into the local
     *  cache, sets the in-memory denylist, and purges any discovered rows
     *  whose email is now denylisted. Preferred rows are *not* auto-purged on
     *  denylist hit — if the user explicitly added them they win that
     *  conflict; we just log a warning.
     *
     *  Discovered rows from the file are MERGED with whatever the local
     *  message-corpus seeder has produced. Each device contributes its
     *  observed addresses; over time GDrive accumulates the union. */
    applyContactsConfig(cfg: {
        preferred?: { name?: string; email: string; source?: string; organization?: string; org?: string }[];
        denylist?: string[];
        discovered?: { name?: string; email: string; useCount?: number; lastUsed?: number }[];
    }): { preferred: number; discovered: number; purged: number; conflicts: string[] } {
        const preferred = Array.isArray(cfg.preferred) ? cfg.preferred : [];
        const denylist = Array.isArray(cfg.denylist) ? cfg.denylist : [];
        const discovered = Array.isArray(cfg.discovered) ? cfg.discovered : [];
        this.setContactsDenylist(denylist);

        // Wipe and rewrite preferred-tier rows owned by contacts.jsonc.
        // The address-book UI's legacy `upsertContact` still writes
        // source='manual' rows; those are owned by the address-book code
        // path, not contacts.jsonc, so we leave them alone here.
        this.db.exec("DELETE FROM contacts WHERE source NOT IN ('google', 'discovered', 'manual')");

        const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
        const now = Date.now();
        const ins = this.db.prepare(
            `INSERT OR IGNORE INTO contacts (source, name, email, organization, last_used, use_count, updated_at)
             VALUES (?, ?, ?, ?, 0, 0, ?)`
        );
        const denySet = new Set(denylist.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
        const conflicts: string[] = [];
        let inserted = 0;
        for (const entry of preferred) {
            if (!entry) continue;
            const email = (entry.email || "").trim();
            if (!email || !VALID.test(email)) continue;
            if (denySet.has(email.toLowerCase())) {
                conflicts.push(email);
                continue;
            }
            const source = (entry.source || "preferred").trim() || "preferred";
            const name = (entry.name || "").trim();
            const org = (entry.organization || entry.org || "").trim();
            try {
                const r = ins.run(source, name, email, org, now);
                if ((r as any).changes) inserted++;
            } catch { /* dup row, skip */ }
        }

        // Merge discovered[] from cloud into local cache. For each entry:
        // existing row wins on use_count (max), name fills if empty, lastUsed
        // is max. Missing rows are inserted. Denylisted entries skipped.
        const insDiscovered = this.db.prepare(
            "INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, ?, ?)"
        );
        const updDiscovered = this.db.prepare(
            `UPDATE contacts SET use_count = max(use_count, ?),
                                 last_used = max(last_used, ?),
                                 name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
                                 updated_at = ?
             WHERE id = ?`
        );
        let discoveredAdded = 0;
        for (const entry of discovered) {
            if (!entry) continue;
            const email = (entry.email || "").trim();
            if (!email || !VALID.test(email)) continue;
            const lower = email.toLowerCase();
            if (denySet.has(lower)) continue;
            if (isJunkContact(lower, entry.name || "")) continue;
            const name = (entry.name || "").trim();
            const useCount = Math.max(0, entry.useCount || 0);
            const lastUsed = Math.max(0, entry.lastUsed || 0);
            const existing = this.db.prepare(
                "SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?"
            ).get(lower) as { id: number } | undefined;
            if (!existing) {
                insDiscovered.run(name, email, lastUsed, useCount, now);
                discoveredAdded++;
            } else {
                updDiscovered.run(useCount, lastUsed, name, name, now, existing.id);
            }
        }

        // Purge discovered rows for any denylisted email.
        const purge = this.db.prepare("DELETE FROM contacts WHERE source = 'discovered' AND lower(email) = ?");
        let purged = 0;
        for (const e of denySet) {
            const r = purge.run(e);
            purged += Number((r as any).changes || 0);
        }

        if (conflicts.length > 0) {
            console.warn(`  [contacts] config: ${conflicts.length} preferred entries also appear in denylist — denylist wins, entries skipped: ${conflicts.join(", ")}`);
        }
        console.log(`  [contacts] config applied: ${inserted} preferred + ${discoveredAdded} discovered row(s), ${denySet.size} denylisted, ${purged} discovered row(s) purged`);
        return { preferred: inserted, discovered: discoveredAdded, purged, conflicts };
    }

    /** Build the contacts.jsonc shape from current DB state — for round-trip
     *  to GDrive. Preferred-tier rows come from anything not in the reserved
     *  system sources; discovered comes from `source='discovered'` rows;
     *  denylist comes from the in-memory set (set by applyContactsConfig).
     *  Caller is responsible for actually writing the cloud copy. */
    exportContactsConfig(): {
        preferred: { name: string; email: string; source: string; organization?: string }[];
        denylist: string[];
        discovered: { name: string; email: string; useCount: number; lastUsed: number }[];
    } {
        const preferredRows = this.db.prepare(
            `SELECT name, email, source, organization
             FROM contacts
             WHERE source NOT IN ('google', 'discovered', 'manual')
             ORDER BY source, lower(email), lower(name)`
        ).all() as { name: string; email: string; source: string; organization: string }[];
        const discoveredRows = this.db.prepare(
            `SELECT name, email, use_count, last_used
             FROM contacts
             WHERE source = 'discovered'
             ORDER BY use_count DESC, last_used DESC, lower(email)`
        ).all() as { name: string; email: string; use_count: number; last_used: number }[];
        return {
            preferred: preferredRows.map(r => {
                const out: any = { name: r.name || "", email: r.email, source: r.source };
                if (r.organization) out.organization = r.organization;
                return out;
            }),
            denylist: Array.from(this._denylist),
            discovered: discoveredRows.map(r => ({
                name: r.name || "",
                email: r.email,
                useCount: r.use_count,
                lastUsed: r.last_used,
            })),
        };
    }

    /** Search contacts by name or email prefix.
     *
     *  Source-tier bonus is what makes the curated address book win against
     *  passive corpus harvest. Anything in `contacts.jsonc#preferred[]` (any
     *  source value other than the two reserved system sources) gets the
     *  highest tier — that's the user's explicit address book and overrides
     *  Google. Google sits in the middle (the auto-synced address book).
     *  `discovered` is the corpus-harvested floor.
     *
     *  Multi-name-per-email is supported: the same email can carry distinct
     *  (source, name) rows — Bob's wife and Bob Smith both at bob@example.com
     *  surface as two rows, each typing-completable by their own name. */
    searchContacts(query: string, limit = 10): { name: string; email: string; source: string; useCount: number }[] {
        query = (query || "").trim();
        if (!query) return [];
        // Split into whitespace-separated tokens. Each token must appear in
        // name or email — order- and adjacency-independent. So "eleanor elkin"
        // matches "Eleanor Elkin", "Elkin, Eleanor", "Eleanor M Elkin", and
        // "elkin@eleanor.example". The first token gets the prefix bonus for
        // ranking; remaining tokens just have to be present.
        const tokens = query.split(/\s+/).filter(Boolean);
        const firstSubstr = `%${tokens[0]}%`;
        const firstPrefix = `${tokens[0]}%`;
        const tokenWhere = tokens.map(() => "(name LIKE ? OR email LIKE ?)").join(" AND ");
        const tokenParams: string[] = [];
        for (const t of tokens) { tokenParams.push(`%${t}%`, `%${t}%`); }
        let rows: any[];
        try {
            // Source tier: anything not in the two reserved system sources
            // ('google', 'discovered') is preferred-tier — i.e. came out of
            // contacts.jsonc#preferred[]. The user's `source: "work"` /
            // `source: "family"` tags all rank +40 alongside the default
            // `preferred` label.
            rows = this.db.prepare(
                `SELECT name, email, source, use_count, last_used,
                        (CASE
                            WHEN lower(name) LIKE lower(?) THEN 3
                            WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
                            WHEN email LIKE ? OR name LIKE ? THEN 1
                            ELSE 0
                         END) +
                        (CASE
                            WHEN source = 'google' THEN 30
                            WHEN source = 'discovered' THEN 0
                            ELSE 40
                         END) AS match_rank
                 FROM contacts
                 WHERE ${tokenWhere}
                 ORDER BY match_rank DESC, use_count DESC, last_used DESC
                 LIMIT ?`
            ).all(firstPrefix, firstPrefix, firstSubstr, firstSubstr, ...tokenParams, limit * 2) as any[];
        } catch (e: any) {
            console.error(`  [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
            rows = this.db.prepare(
                `SELECT name, email, source, use_count, last_used, 0 AS match_rank
                 FROM contacts
                 WHERE ${tokenWhere}
                 ORDER BY use_count DESC, last_used DESC
                 LIMIT ?`
            ).all(...tokenParams, limit * 2) as any[];
        }
        // Filter out denylisted emails as a defense-in-depth — applyContactsConfig
        // already purges discovered rows on denylist, but a Google sync that
        // reintroduced a denylisted address would otherwise leak through.
        rows = rows.filter(r => !this.isAddressDenylisted((r.email || "").toLowerCase()));
        const now = Date.now();
        const HALF_LIFE_MS = 30 * 86400_000;
        const score = (r: any) => (r.match_rank || 0) * 10_000
            + (r.use_count || 0) * Math.pow(0.5, Math.max(0, now - (r.last_used || 0)) / HALF_LIFE_MS);
        rows.sort((a, b) => score(b) - score(a));
        rows = rows.slice(0, limit);
        return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
    }

    /** List all contacts (address-book view) with pagination + optional filter. */
    listContacts(query: string, page = 1, pageSize = 100): { items: { name: string; email: string; source: string; googleId: string | null; useCount: number; lastUsed: number }[]; total: number; page: number; pageSize: number } {
        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.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params) as any;
        const offset = (page - 1) * pageSize;
        const rows = this.db.prepare(
            `SELECT name, email, source, google_id, use_count, last_used FROM contacts
             ${whereClause}
             ORDER BY use_count DESC, last_used DESC
             LIMIT ? OFFSET ?`
        ).all(...params, pageSize, offset) as any[];
        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,
        };
    }

    /** Update or insert a contact manually (from the address book UI). */
    upsertContact(name: string, email: string): void {
        if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) {
            throw new Error(`Invalid email: ${email}`);
        }
        const now = Date.now();
        const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(email) as any;
        if (existing) {
            this.db.prepare(
                "UPDATE contacts SET name = ?, updated_at = ? WHERE email = ?"
            ).run(name, now, email);
        } else {
            this.db.prepare(
                "INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('manual', ?, ?, ?, 0, ?)"
            ).run(name, email, now, now);
        }
    }

    /** Delete a contact by email (address book UI). */
    deleteContact(email: string): void {
        this.db.prepare("DELETE FROM contacts WHERE email = ?").run(email);
    }

    /** Delete contact rows by Google People resourceName. Used by the
     *  incremental People sync when a person comes back with `metadata.deleted = true`
     *  — the email may have already changed/disappeared, but the resourceName
     *  is stable. Removes all rows tied to that Google identity (a single
     *  contact can have multiple email addresses, each is its own row). */
    deleteContactByGoogleId(googleId: string): number {
        if (!googleId) return 0;
        const r = this.db.prepare("DELETE FROM contacts WHERE google_id = ?").run(googleId);
        return Number((r as any).changes || 0);
    }

    // ── Search ──

    /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
    searchMessages(query: string, page = 1, pageSize = 50, accountId?: string, folderId?: number): PagedResult<MessageEnvelope> {
        query = (query || "").trim();
        // Parse qualifiers (C45: extended set — date:, has:, is:, folder:).
        let ftsQuery = "";
        const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];

        // Extra SQL where-clauses for qualifiers that don't map to FTS columns.
        const extraWhere: string[] = [];
        const extraParams: any[] = [];
        // Parse a "1d", "1w", "2024-01-15", "yesterday", "today" etc. into ms epoch.
        const parseRel = (s: string): number | null => {
            const lower = s.toLowerCase().trim();
            if (lower === "today") { const d = new Date(); d.setHours(0,0,0,0); return d.getTime(); }
            if (lower === "yesterday") { const d = new Date(); d.setHours(0,0,0,0); return d.getTime() - 86400_000; }
            const rel = lower.match(/^(\d+)([dwmy])$/);
            if (rel) {
                const n = parseInt(rel[1]);
                const unit = rel[2];
                const ms = unit === "d" ? n * 86400_000
                    : unit === "w" ? n * 7 * 86400_000
                    : unit === "m" ? n * 30 * 86400_000
                    : n * 365 * 86400_000;
                return Date.now() - ms;
            }
            const ts = Date.parse(s);
            return isNaN(ts) ? null : ts;
        };

        for (const part of parts) {
            const fromMatch = part.match(/^from:(.+)$/i);
            const toMatch = part.match(/^to:(.+)$/i);
            const subjectMatch = part.match(/^subject:(.+)$/i);
            const dateMatch = part.match(/^date:([><]?=?)(.+)$/i);
            const afterMatch = part.match(/^after:(.+)$/i);
            const beforeMatch = part.match(/^before:(.+)$/i);
            const hasMatch = part.match(/^has:(.+)$/i);
            const isMatch = part.match(/^is:(.+)$/i);
            const folderMatch = part.match(/^folder:(.+)$/i);

            if (fromMatch) {
                const term = fromMatch[1].replace(/"/g, "");
                ftsQuery += `(from_name:${term} OR from_address:${term}) `;
            } else if (toMatch) {
                const term = toMatch[1].replace(/"/g, "");
                ftsQuery += `(to_text:${term} OR cc_text:${term}) `;
            } else if (subjectMatch) {
                const term = subjectMatch[1].replace(/"/g, "");
                ftsQuery += `subject:${term} `;
            } else if (dateMatch || afterMatch || beforeMatch) {
                const op = dateMatch ? (dateMatch[1] || "=") : (afterMatch ? ">" : "<");
                const valStr = dateMatch ? dateMatch[2] : (afterMatch ? afterMatch[1] : beforeMatch![1]);
                const ts = parseRel(valStr.replace(/"/g, ""));
                if (ts !== null) {
                    if (op === ">" || op === ">=") { extraWhere.push("m.date >= ?"); extraParams.push(ts); }
                    else if (op === "<" || op === "<=") { extraWhere.push("m.date <= ?"); extraParams.push(ts); }
                    else { extraWhere.push("m.date >= ? AND m.date < ?"); extraParams.push(ts, ts + 86400_000); }
                }
            } else if (hasMatch) {
                const v = hasMatch[1].toLowerCase().replace(/"/g, "");
                if (v === "attachment" || v === "attachments") { extraWhere.push("m.has_attachments = 1"); }
            } else if (isMatch) {
                const v = isMatch[1].toLowerCase().replace(/"/g, "");
                if (v === "flagged" || v === "starred") extraWhere.push("m.flags_json LIKE '%\\\\Flagged%'");
                else if (v === "unread") extraWhere.push("m.flags_json NOT LIKE '%\\\\Seen%'");
                else if (v === "read" || v === "seen") extraWhere.push("m.flags_json LIKE '%\\\\Seen%'");
                else if (v === "answered") extraWhere.push("m.flags_json LIKE '%\\\\Answered%'");
                else if (v === "draft") extraWhere.push("m.flags_json LIKE '%\\\\Draft%'");
            } else if (folderMatch) {
                const v = folderMatch[1].replace(/"/g, "");
                extraWhere.push("LOWER(f.name) LIKE ?");
                extraParams.push(`%${v.toLowerCase()}%`);
            } else {
                // Unqualified — search everything.
                let term = part.replace(/^\/|\/$/g, "");
                if (term.includes("|")) {
                    const alts = term.split("|").filter(Boolean).map(t => `${t}*`).join(" OR ");
                    ftsQuery += `(${alts}) `;
                } else {
                    ftsQuery += `${term}* `;
                }
            }
        }

        ftsQuery = ftsQuery.trim();
        // If the user typed only qualifier-only terms (e.g. "is:flagged after:1w"),
        // FTS query is empty — match-all surrogate.
        if (!ftsQuery) ftsQuery = "*";

        const offset = (page - 1) * pageSize;

        try {
            let scopeWhere = "";
            const scopeParams: any[] = [];
            if (accountId && folderId) {
                scopeWhere = " AND m.account_id = ? AND m.folder_id = ?";
                scopeParams.push(accountId, folderId);
            } else if (accountId) {
                scopeWhere = " AND m.account_id = ?";
                scopeParams.push(accountId);
            }
            if (extraWhere.length > 0) {
                scopeWhere += " AND " + extraWhere.join(" AND ");
                scopeParams.push(...extraParams);
            }

            const countRow = this.db.prepare(
                `SELECT COUNT(*) as cnt FROM messages m JOIN messages_fts fts ON m.id = fts.rowid WHERE messages_fts MATCH ?${scopeWhere}`
            ).get(ftsQuery, ...scopeParams) as any;
            const total = countRow?.cnt || 0;

            const rows = this.db.prepare(
                `SELECT m.*, f.name AS folder_name FROM messages m
                 JOIN messages_fts fts ON m.id = fts.rowid
                 LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
                 WHERE messages_fts MATCH ?${scopeWhere}
                 ORDER BY m.date DESC
                 LIMIT ? OFFSET ?`
            ).all(ftsQuery, ...scopeParams, pageSize, offset) as any[];

            const items: MessageEnvelope[] = rows.map(r => ({
                id: r.id,
                accountId: r.account_id,
                folderId: r.folder_id,
                folderName: r.folder_name || "",
                uid: r.uid,
                messageId: r.message_id || "",
                inReplyTo: r.in_reply_to || "",
                references: JSON.parse(r.refs || "[]"),
                date: r.date,
                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
            }));

            return { items, total, page, pageSize };
        } catch (e: any) {
            console.error(`Search error: ${e.message}`);
            return { items: [], total: 0, page, pageSize };
        }
    }

    /** Rebuild FTS index from existing messages */
    rebuildSearchIndex(): number {
        // Drop and recreate in case schema changed
        try { this.db.exec("DROP TABLE IF EXISTS messages_fts"); } catch { /* ignore */ }
        this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
            subject, from_name, from_address, to_text, cc_text, body_text,
            content=messages, content_rowid=id
        )`);

        // Use a single transaction + prepared statement for speed (~50x faster than individual inserts)
        const insert = this.db.prepare(
            "INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)"
        );
        const rows = this.db.prepare(
            "SELECT id, subject, from_name, from_address, to_json, cc_json, preview FROM messages"
        ).all() as any[];

        let count = 0;
        this.db.exec("BEGIN");
        try {
            for (const r of rows) {
                const to = JSON.parse(r.to_json || "[]");
                const cc = JSON.parse(r.cc_json || "[]");
                const toText = to.map((a: any) => `${a.name} ${a.address}`).join(" ");
                const ccText = cc.map((a: any) => `${a.name} ${a.address}`).join(" ");
                try {
                    insert.run(r.id, r.subject, r.from_name, r.from_address, toText, ccText, r.preview);
                    count++;
                } catch { /* skip duplicates */ }
            }
            this.db.exec("COMMIT");
        } catch (e) {
            this.db.exec("ROLLBACK");
            throw e;
        }
        return count;
    }

    // ── Sync Actions ──

    /** Queue a local action for later sync to IMAP */
    queueSyncAction(accountId: string, action: string, uid: number, folderId: number, extra?: {
        targetFolderId?: number;
        flags?: string[];
        rawMessage?: string;
    }): void {
        try {
            this.db.prepare(
                `INSERT OR REPLACE INTO sync_actions (account_id, action, uid, folder_id, target_folder_id, flags_json, raw_message, created_at)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
            ).run(
                accountId, action, uid, folderId,
                extra?.targetFolderId || null,
                extra?.flags ? JSON.stringify(extra.flags) : null,
                extra?.rawMessage || null,
                Date.now()
            );
        } catch { /* UNIQUE constraint — already queued */ }
    }

    /** Get all pending sync actions for an account */
    getPendingSyncActions(accountId: string): {
        id: number; action: string; uid: number; folderId: number;
        targetFolderId: number; flags: string[]; rawMessage: string;
        attempts: number;
    }[] {
        const rows = this.db.prepare(
            "SELECT * FROM sync_actions WHERE account_id = ? ORDER BY created_at"
        ).all(accountId) as any[];
        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,
        }));
    }

    /** Remove a completed sync action */
    completeSyncAction(id: number): void {
        this.db.prepare("DELETE FROM sync_actions WHERE id = ?").run(id);
    }

    /** Mark a sync action as failed */
    failSyncAction(id: number, error: string): void {
        this.db.prepare(
            "UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE id = ?"
        ).run(error, id);
    }

    /** Count pending sync actions for an account */
    getPendingSyncCount(accountId: string): number {
        const r = this.db.prepare(
            "SELECT COUNT(*) as cnt FROM sync_actions WHERE account_id = ?"
        ).get(accountId) as any;
        return r?.cnt || 0;
    }

    /** Count total pending sync actions across all accounts */
    getTotalPendingSyncCount(): number {
        const r = this.db.prepare("SELECT COUNT(*) as cnt FROM sync_actions").get() as any;
        return r?.cnt || 0;
    }
}
