/**
 * Store — the nexus. Owns the local database, the .eml file store, the
 * operations API, and the event bus. Single source of truth for everything
 * about a mailx account that lives on this device.
 *
 *   UI ──┐                                ┌── Sync clients (IMAP, Gmail API, …)
 *        │  reads / writes / subscribes  │   read pending actions, commit
 *        ↓                                ↓   server-discovered changes
 *                          [ Store ]
 *
 * Contract: every method here mutates local-only state. No IMAP, no Gmail,
 * no SMTP, no DNS, no Drive API. Mutations emit on the bus; subscribers
 * (UI in another process, in-process reconciler, etc.) react. Sync clients
 * (mailx-imap, mailx-sync) hold a Store reference and never touch the
 * underlying MailxDB / FileMessageStore directly.
 *
 * This was previously named `Store` and lived in mailx-service. It
 * moved to mailx-store because mailx-imap (and other sync clients) must
 * be able to consume it without depending on mailx-service — that's the
 * "arrow points the right way" property of the architecture.
 */

import * as fs from "node:fs";
import { parseSerial } from "./parse-serial.js";
import { storeBus } from "./bus.js";
import { MailxDB } from "./db.js";
import { FileMessageStore } from "./file-store.js";
import type { StoreBus } from "./bus.js";
import type {
    MessageEnvelope, MessageQuery, PagedResult,
} from "@bobfrankston/mailx-types";
import { sanitizeHtml } from "@bobfrankston/mailx-types";
import { loadSettings, loadAllowlist } from "@bobfrankston/mailx-settings";
import { sniffAndFixCharset } from "./charset.js";

/** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
 *  mailparser only exposes ONE of mail/url even when both are present, so we
 *  also scan the raw header text for the full set of angle-bracketed URIs. */
function parseListUnsubscribe(headers: any): { listUnsubscribeMail: string; listUnsubscribeHttp: string; listUnsubscribeOneClick: boolean } {
    let mail = "";
    let http = "";
    let oneClick = false;

    const raw = headers.get("list-unsubscribe");
    const rawStr = typeof raw === "string" ? raw : (raw && typeof (raw as any).text === "string" ? (raw as any).text : "");
    if (rawStr) {
        const matches = rawStr.match(/<([^>]+)>/g) || [];
        for (const m of matches) {
            const url = m.slice(1, -1).trim();
            if (!mail && /^mailto:/i.test(url)) mail = url;
            else if (!http && /^https?:/i.test(url)) http = url;
        }
    }
    if (!mail && !http) {
        const listHeaders = headers.get("list");
        if (listHeaders?.unsubscribe) {
            const unsub = listHeaders.unsubscribe;
            if (unsub.url) http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
            if (unsub.mail) mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
        }
    }

    const post = headers.get("list-unsubscribe-post");
    const postStr = typeof post === "string" ? post : (post && typeof (post as any).text === "string" ? (post as any).text : "");
    if (postStr && /one-?click/i.test(postStr)) oneClick = true;

    return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
}

/** What the UI gets back from a body read. Mirrors the historical
 *  `getMessage` shape so call-site migration is mechanical. `cached: false`
 *  means the body isn't on disk yet — the UI shows a "downloading…"
 *  placeholder and listens for `bodyAvailable` to re-render. The reconciler
 *  is responsible for actually fetching and emitting the event. */
export interface StoreMessage extends MessageEnvelope {
    bodyHtml: string;
    bodyText: string;
    hasRemoteContent: boolean;
    remoteAllowed: boolean;
    attachments: Array<{ id: number; filename: string; mimeType: string; size: number; contentId: string }>;
    cached: boolean;          // false = body not rendered (see bodyOnDisk to disambiguate)
    /** Disambiguates `cached: false`:
     *   - bodyOnDisk false → the .eml is NOT on disk; caller should queue
     *     an IMAP/Gmail fetch.
     *   - bodyOnDisk true  → the .eml IS on disk; a parse is in flight.
     *     Caller MUST NOT queue a fetch — just wait for the `bodyAvailable`
     *     bus event. Queuing a fetch here caused the 10-Hz "Loading body…"
     *     loop (Bob 2026-05-15): cached:false → MailxService fetched →
     *     reconciler re-published bodyAvailable → viewer re-called → repeat. */
    bodyOnDisk: boolean;
    deliveredTo: string;
    returnPath: string;
    listUnsubscribe: string;
    listUnsubscribeMail: string;
    listUnsubscribeHttp: string;
    listUnsubscribeOneClick: boolean;
    emlPath: string;
    isFlagged: boolean;
    /** X-Mailx-Draft-ID header value (empty for non-draft messages). When
     *  the user re-opens a draft for edit, the compose layer threads this
     *  back into saveDraft so the server-side dedup keeps replacing the
     *  same draft instead of stacking up a new one every save. */
    mailxDraftId: string;
}

export class Store {
    // Parsed-body LRU. `simpleParser` is the dominant cost of getMessage on
    // a body that's already on disk — 100-500 ms for a typical Gmail message
    // (heavier multipart/alternative + embedded-image swizzling than plainer
    // IMAP mail). Stash the parsed result keyed by .eml path + mtime so
    // repeat views of the same message return in microseconds.
    //
    // Invalidation is implicit: when the underlying .eml is overwritten
    // (rare — bodies are immutable once cached, but reconcile can re-fetch
    // on UIDVALIDITY change), the new mtime won't match the cached key.
    // Capacity 200 entries × ~50 kB parsed envelope = ~10 MB ceiling.
    private static readonly PARSED_LRU_CAPACITY = 200;
    private parsedLru = new Map<string, StoreMessage>();
    private parsedLruGet(key: string): StoreMessage | undefined {
        const v = this.parsedLru.get(key);
        if (!v) return undefined;
        // Bump recency.
        this.parsedLru.delete(key);
        this.parsedLru.set(key, v);
        return v;
    }
    private parsedLruPut(key: string, msg: StoreMessage): void {
        this.parsedLru.delete(key);
        this.parsedLru.set(key, msg);
        while (this.parsedLru.size > Store.PARSED_LRU_CAPACITY) {
            const oldest = this.parsedLru.keys().next().value;
            if (oldest === undefined) break;
            this.parsedLru.delete(oldest);
        }
    }

    // In-flight parse coalescing. Concurrent getMessage calls for the same
    // body (same path|mtime cacheKey) must share ONE parse — otherwise a
    // viewer that re-requests while the first parse is still queued enqueues
    // a duplicate, and N retries become N parses stacked in the parse-worker
    // queue. Observed 2026-05-15: the same .eml parsed 3-4× in a burst, and
    // a genuine click then waited 25 s behind the pile-up. The map entry is
    // removed when the parse settles (success OR failure — a failed parse
    // must not poison the key).
    private parseInflight = new Map<string, Promise<import("mailparser").ParsedMail>>();

    // Allowlist + settings caches. Both files live on the GDrive-mounted
    // shared dir; their sync `readFileSync` calls in `loadAllowlist()` and
    // `loadSettings()` can stall for seconds per call. getMessage runs on
    // every preview click, so paying that twice each click made .eml
    // display "go to GDrive" even though the body itself is local. Cached
    // here; invalidated externally via invalidateConfigCaches() when the
    // parent service receives a `configChanged` event.
    private _allowlistCache: any | null = null;
    private _settingsCache: any | null = null;
    private getCachedAllowlist(): any {
        if (!this._allowlistCache) this._allowlistCache = loadAllowlist();
        return this._allowlistCache;
    }
    private getCachedSettings(): any {
        if (!this._settingsCache) this._settingsCache = loadSettings();
        return this._settingsCache;
    }
    invalidateConfigCaches(): void {
        this._allowlistCache = null;
        this._settingsCache = null;
    }

    constructor(
        /** SQLite metadata index. Exposed as a public field — sync clients
         *  (mailx-imap, mailx-sync) read/write through it during the
         *  refactor. Future state: every external mutation routes through
         *  a Store method that emits a bus event; raw `store.db.X()` calls
         *  shrink to zero. Today, this is read mostly. */
        public readonly db: MailxDB,
        /** .eml file backend. Same migration story as `db` — sync clients
         *  read/write directly today, will route through Store methods. */
        public readonly bodyStore: FileMessageStore,
        /** Event bus for Store mutations. Defaults to the process-singleton
         *  so cross-package subscribers (bin/mailx.ts forwarder → WebView,
         *  in-process reconciler triggers) see all writes without explicit
         *  wiring. Tests can pass a fresh StoreBus for isolation. */
        public readonly bus: StoreBus = storeBus,
    ) {}

    // ── Account list (read-only here; mutations go through MailxService
    //    until the cloud-write path is part of the queue too) ──

    /** DB-shape account list (id/name/email/lastSync). The richer
     *  AccountConfig (with imap/smtp/etc.) lives in accounts.jsonc and is
     *  loaded by mailx-settings, not the DB — that path stays in
     *  MailxService until step 3 of the local-first plan. */
    getAccounts(): { id: string; name: string; email: string; lastSync: number }[] {
        return this.db.getAccounts();
    }

    // ── Folders ──

    getFolders(accountId: string): any[] {
        return this.db.getFolders(accountId);
    }

    /** Look up a folder by RFC 6154 specialUse tag (`trash`, `drafts`, `sent`,
     *  `junk`, etc.) for the given account. Falls back to a case-insensitive
     *  path match for legacy rows where specialUse never got tagged.
     *  Returns null when the account has no such folder configured. */
    findSpecialFolder(accountId: string, specialUse: string): { id: number; path: string } | null {
        const folders = this.db.getFolders(accountId);
        const f = folders.find(x =>
            x.specialUse === specialUse ||
            x.path.toLowerCase() === specialUse.toLowerCase()
        );
        return f ? { id: f.id, path: f.path } : null;
    }

    // ── Message envelopes ──

    /** Single envelope by (account, uid, folder). Null when the row isn't
     *  in the DB — caller decides whether to show "deleted" or queue a
     *  server lookup via the reconciler. */
    getMessageEnvelope(accountId: string, uid: number, folderId?: number): MessageEnvelope | null {
        const env = this.db.getMessageByUid(accountId, uid, folderId);
        return env || null;
    }

    /** Paginated message list for a (account, folder, ...) query. */
    getMessages(query: MessageQuery): PagedResult<MessageEnvelope> {
        return this.db.getMessages(query);
    }

    /** All-Inboxes view: union of every account's INBOX, paginated. */
    getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false, dateBasis: "sent" | "received" = "sent"): PagedResult<MessageEnvelope> {
        return this.db.getUnifiedInbox(page, pageSize, flaggedOnly, dateBasis);
    }

    /** Local FTS5 search. Server-scope search is the reconciler's job. */
    searchMessages(query: string, page = 1, pageSize = 50, accountId?: string, folderId?: number, includeTrashSpam = false): PagedResult<MessageEnvelope> {
        return this.db.searchMessages(query, page, pageSize, accountId, folderId, includeTrashSpam);
    }

    // ── Message body (read-from-disk only) ──

    /** Read a fully-parsed message (envelope + body + attachments) entirely
     *  from local state. Returns null when the envelope isn't known.
     *  Returns `{ ...envelope, cached: false }` when the envelope is known
     *  but the body file isn't on disk — UI shows a placeholder and the
     *  reconciler queues the fetch.
     *
     *  `allowRemote=true` skips HTML sanitization. Used when the user has
     *  explicitly allowed remote content for this sender / domain. */
    async getMessage(accountId: string, uid: number, allowRemote: boolean, folderId?: number): Promise<StoreMessage | null> {
        const envelope: any = this.db.getMessageByUid(accountId, uid, folderId);
        if (!envelope) return null;

        const allowList = this.getCachedAllowlist() as any;
        const senderAddr = (envelope.from?.address || "").toLowerCase();
        const senderDomain = senderAddr.split("@")[1] || "";
        // Recipient allowlist matches against every recipient-ish address, not
        // just To: a mailing list often carries other subscribers in To/Cc and
        // delivers to the user's allowlisted alias only via Delivered-To (added
        // post-parse below).
        const recipients = (allowList.recipients || []).map((r: string) => (r || "").toLowerCase());
        const rcptAddrs = [...(envelope.to || []), ...(envelope.cc || [])]
            .map((a: any) => (a.address || "").toLowerCase());

        // Allowlist auto-allow: trusted sender / domain / recipient skips
        // sanitization. Same rule as the legacy service implementation.
        if (!allowRemote) {
            const senders = (allowList.senders || []).map((s: string) => (s || "").toLowerCase());
            const domains = (allowList.domains || []).map((d: string) => (d || "").toLowerCase());
            if (senders.includes(senderAddr) ||
                domains.includes(senderDomain) ||
                rcptAddrs.some((a: string) => recipients.includes(a))) {
                allowRemote = true;
            }
        }

        const isFlagged = !!(
            (allowList.flaggedSenders || []).some((s: string) => (s || "").toLowerCase() === senderAddr) ||
            (allowList.flaggedDomains || []).some((d: string) => (d || "").toLowerCase() === senderDomain)
        );

        // Resolve body path: prefer the row's own bodyPath, fall back to
        // the historical lookup. Either may be empty (body never fetched).
        let storedPath = envelope.bodyPath || "";
        if (!storedPath) storedPath = this.db.getMessageBodyPath(accountId, uid, folderId) || "";

        const empty: StoreMessage = {
            ...envelope,
            bodyHtml: "", bodyText: "",
            hasRemoteContent: false, remoteAllowed: allowRemote,
            attachments: [],
            cached: false,
            bodyOnDisk: false,    // overridden to true on the parse-pending return
            deliveredTo: "", returnPath: "",
            listUnsubscribe: "", listUnsubscribeMail: "", listUnsubscribeHttp: "", listUnsubscribeOneClick: false,
            emlPath: "",
            isFlagged,
        };

        // No body file → caller should queue a fetch (bodyOnDisk stays false).
        if (!storedPath) return empty;
        if (!await this.bodyStore.hasByPath(storedPath)) return empty;

        // Parse-cache lookup. Key = path + mtime so a re-fetched body
        // (mtime changes) invalidates automatically. mtime stat is cheap
        // (sub-ms) compared to the parse it avoids (~100-500 ms).
        let mtimeMs = 0;
        try { mtimeMs = (await fs.promises.stat(storedPath)).mtimeMs; }
        catch { /* will fall through and fail at readByPath */ }
        const cacheKey = `${storedPath}|${mtimeMs}`;
        const cached = this.parsedLruGet(cacheKey);
        if (cached) {
            // Allowlist state can change between views even with the same
            // body cached; recompute the volatile fields and overlay. OR with
            // the cached flag: if the body was already parsed-and-allowed (e.g.
            // via a Delivered-To recipient match, which the early envelope-only
            // check can't see), keep it allowed — the cached HTML is unsanitized
            // and the flag must agree.
            return { ...cached, remoteAllowed: allowRemote || cached.remoteAllowed, isFlagged };
        }

        // Synchronous parse. The .eml is on disk; read + parse it inline
        // and return the fully-rendered message. The parse runs on the
        // parse-worker thread (off the main event loop) — the IPC promise
        // just awaits the worker's reply, ~50-200 ms on a warm worker.
        //
        // 2026-05-15: this REPLACED an async "return envelope now, parse
        // later, emit bodyAvailable" split. That split overloaded
        // `cached:false` (was it "no body file" or "parse pending"?),
        // which made MailxService queue redundant fetches, which churned
        // the .eml mtime, which invalidated the cache key, which spawned
        // duplicate parses — a 10 Hz "Loading body…" loop and 30-second
        // queue-contention parses. Synchronous parse makes the whole
        // class structurally impossible: getMessage either returns the
        // body or returns `cached:false` meaning exactly "fetch it."
        // Coalesce: if another getMessage is already reading+parsing this
        // exact body, await its parse instead of starting a duplicate.
        let parsed: import("mailparser").ParsedMail;
        const existingParse = this.parseInflight.get(cacheKey);
        if (existingParse) {
            parsed = await existingParse;
        } else {
            const _parseT0 = Date.now();
            const fresh = (async (): Promise<import("mailparser").ParsedMail> => {
                const raw: Buffer = await this.bodyStore.readByPath(storedPath);
                const adjusted = sniffAndFixCharset(raw);
                const result = await parseSerial(adjusted);
                const _parseMs = Date.now() - _parseT0;
                if (_parseMs > 50) {
                    console.log(`  [parse] simpleParser ${_parseMs}ms for ${(raw.length / 1024).toFixed(0)} KB (${storedPath})`);
                }
                return result;
            })();
            this.parseInflight.set(cacheKey, fresh);
            try { parsed = await fresh; }
            finally { this.parseInflight.delete(cacheKey); }
        }
        let bodyHtml = parsed.html || "";
        const bodyText = parsed.text || "";
        // Charset diagnostic. If the parsed body has U+FFFD, distinguish the
        // two very different causes:
        //  - SOURCE already contains U+FFFD (raw EF BF BD bytes, or QP
        //    =EF=BF=BD) → the message was composed/replied during the pre-fix
        //    charset window; the original bytes are gone — UNRECOVERABLE, and
        //    NOT a current bug.
        //  - source is clean but the parse produced U+FFFD → a live
        //    charset-decode bug worth chasing.
        if (bodyHtml.includes("�") || bodyText.includes("�")) {
            let sourceCorrupt = false;
            try {
                const src = await this.bodyStore.readByPath(storedPath);
                sourceCorrupt = src.includes(Buffer.from([0xEF, 0xBF, 0xBD]))
                    || src.toString("latin1").toUpperCase().includes("=EF=BF=BD");
            } catch { /* */ }
            if (sourceCorrupt) {
                console.error(`  [charset] U+FFFD baked into SOURCE — acct=${accountId} uid=${uid} path=${storedPath} — corrupt at compose time (pre-fix), unrecoverable`);
            } else {
                console.error(`  [charset] U+FFFD from PARSE — acct=${accountId} uid=${uid} path=${storedPath} — LIVE charset-decode bug, source is clean`);
            }
        }
        // Backfill FTS body_text now that we've parsed the body. upsertMessage
        // only had the short `preview` snippet at index time; without this
        // backfill, searches miss any word that only appears deeper in the
        // body. Fire-and-forget — failures are non-fatal, never block the
        // user's preview render.
        if (bodyText && envelope.id) {
            try { this.db.updateFtsBody(envelope.id, bodyText); }
            catch { /* */ }
        }
        let hasRemoteContent = false;
        // Filter out "spurious" attachments: mailing-list footers and signature
        // blocks frequently arrive as a separate text/plain MIME part with
        // Content-Disposition: inline and no filename. mailparser dutifully
        // surfaces them as attachments. Showing them as attachment chips
        // confuses the user (Bob 2026-05-13: spurious image-shaped chip on
        // a list message that had no real attachment). Rule: if the part is
        // text/* AND has no filename AND is dispositionally inline (or no
        // disposition at all), it's body content, not a real attachment.
        // Keep the original index even for filtered rows so getAttachment's
        // index lookup still works for the surviving ones.
        const attachments = (parsed.attachments || [])
            .map((a, i) => {
                const mime = (a.contentType || "application/octet-stream").toLowerCase();
                const disposition = (a.contentDisposition || "").toLowerCase();
                const isSpuriousTextPart = mime.startsWith("text/")
                    && !a.filename
                    && (disposition === "inline" || disposition === "");
                return { a, i, isSpuriousTextPart };
            })
            .filter(x => !x.isSpuriousTextPart)
            .map(x => ({
                id: x.i,
                filename: x.a.filename || `attachment-${x.i}`,
                mimeType: x.a.contentType || "application/octet-stream",
                size: x.a.size || 0,
                contentId: x.a.contentId || "",
            }));

        // Header extraction — Delivered-To, Return-Path, List-Unsubscribe.
        // Each delivery agent PREPENDS its Delivered-To, so the FIRST
        // (topmost) header is the final delivery to the user's actual
        // mailbox; later ones are earlier forwarding hops / internal
        // routing artifacts. Take [0]. (The old code took the LAST entry —
        // on a forwarded message that grabbed a stale hop, e.g. a malformed
        // `…@elkin.ws@trap-prot` address — Bob 2026-05-21.)
        // This runs BEFORE sanitization because Delivered-To feeds the
        // recipient-allowlist re-check just below.
        let deliveredTo = "";
        const rawDelivered = parsed.headers.get("delivered-to");
        if (rawDelivered) {
            const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
            const d = deliveredList[0];
            deliveredTo = typeof d === "string" ? d : (d as any)?.text || (d as any)?.address || String(d);
        }

        const hdr = (key: string): string => {
            let v = parsed.headers.get(key);
            if (!v) return "";
            if (Array.isArray(v)) v = v[0];
            if (typeof v === "string") return v;
            if (typeof v === "object" && v !== null) {
                if ("text" in v) return (v as any).text || "";
                if ("value" in v) return String((v as any).value);
                if ("address" in v) return (v as any).address || "";
            }
            return String(v);
        };
        const returnPath = hdr("return-path").replace(/[<>]/g, "");
        const mailxDraftId = hdr("x-mailx-draft-id").trim();
        const { listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } =
            parseListUnsubscribe(parsed.headers);
        const listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;

        // Recipient-allowlist re-check with Delivered-To in hand. The early
        // pass (envelope only) can't see Delivered-To, so a list message whose
        // To/Cc are other subscribers and whose only allowlisted address is the
        // Delivered-To alias (e.g. shsaa@bob.ma) would still be sanitized.
        // Match the bare address out of "Name <addr>" / "<addr>" / "addr".
        if (!allowRemote && deliveredTo) {
            const dAddr = (deliveredTo.match(/[^\s<>]+@[^\s<>]+/)?.[0] || "").toLowerCase();
            if (dAddr && recipients.includes(dAddr)) allowRemote = true;
        }

        if (bodyHtml && !allowRemote) {
            const result = sanitizeHtml(bodyHtml);
            bodyHtml = result.html;
            hasRemoteContent = result.hasRemoteContent;
        }

        const result: StoreMessage = {
            ...envelope,
            bodyHtml, bodyText,
            hasRemoteContent, remoteAllowed: allowRemote,
            attachments,
            cached: true,
            deliveredTo, returnPath,
            listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
            // body_path in the DB is stored relative to the body-store
            // basePath; resolve to absolute so the UI's "Source" button
            // can hand the path to an OS file open without re-deriving
            // basePath every time.
            emlPath: this.bodyStore.absolutePath(storedPath),
            isFlagged,
            mailxDraftId,
        };
        // Memoize for instant re-view of the same message. cacheKey is
        // path+mtime so a re-fetch (mtime changes) invalidates naturally.
        // The LRU is a perf tier only — correctness never depends on it.
        if (mtimeMs > 0) this.parsedLruPut(cacheKey, result);
        return result;
    }

    // ── Calendar / tasks / contacts (read paths) ──

    getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[] {
        return this.db.getCalendarEvents(accountId, fromMs, toMs);
    }

    getTasks(accountId: string, includeCompleted = false): any[] {
        return this.db.getTasks(accountId, includeCompleted);
    }

    searchContacts(query: string, limit = 10): any[] {
        return this.db.searchContacts(query, limit);
    }

    listContacts(query: string, page = 1, pageSize = 100): any {
        return this.db.listContacts(query, page, pageSize);
    }

    // ── Write paths (local-only; mirror to server is queued separately) ──

    /** Update a message's flag set. Local DB write completes synchronously;
     *  the server-mirror enqueue is the caller's responsibility (typically
     *  via SyncQueue.enqueueFlag) so callers that don't want a server push
     *  — pure-local UI state like "pin in pane" — can skip it.
     *
     *  Publishes:
     *    `message:<uuid>` { kind: "flagsChanged" }
     *    `folder:<id>`    (auto fan-out)
     */
    updateFlags(accountId: string, uid: number, folderId: number, flags: string[]): void {
        this.db.updateMessageFlags(accountId, folderId, uid, flags);
        const env: any = this.db.getMessageByUid(accountId, uid, folderId);
        const msgUuid: string | undefined = env?.uuid;
        if (msgUuid) {
            this.bus.publish({
                topic: `message:${msgUuid}`,
                kind: "flagsChanged",
                accountId, folderId, uid, msgUuid, flags,
            });
        }
    }

    /** Move a message between folders in the same account. Adds a tombstone
     *  on the Message-ID so the next sync doesn't re-import the pre-move row
     *  in the source folder before the server-side MOVE completes; tombstone
     *  is cleared on terminal IMAP failure (see processSyncActions).
     *
     *  Returns true if a local row existed and was moved, false otherwise.
     *
     *  Publishes:
     *    `message:<uuid>` { kind: "messageMoved", folderId: source, targetFolderId }
     *    `folder:<source>` and `folder:<target>` (auto fan-out + explicit count)
     */
    moveMessage(accountId: string, uid: number, fromFolderId: number, targetFolderId: number): boolean {
        const env: any = this.db.getMessageByUid(accountId, uid, fromFolderId);
        if (!env) return false;
        if (env.messageId) this.db.addTombstone(accountId, env.messageId, env.subject || "");
        const moved = this.db.moveMessageLocal(accountId, uid, fromFolderId, targetFolderId);
        if (!moved) return false;
        this.db.recalcFolderCounts(fromFolderId);
        this.db.recalcFolderCounts(targetFolderId);
        const msgUuid: string | undefined = env?.uuid;
        if (msgUuid) {
            this.bus.publish({
                topic: `message:${msgUuid}`,
                kind: "messageMoved",
                accountId, folderId: fromFolderId, targetFolderId, uid, msgUuid,
            });
        }
        // Folder count change isn't tied to a specific message uuid — publish
        // to both folder topics directly. (The fan-out only covers the source
        // via the message event's folderId; target needs its own publish.)
        this.bus.publish({
            topic: `folder:${targetFolderId}`,
            kind: "folderCountsChanged",
            accountId, folderId: targetFolderId,
        });
        return true;
    }

    /** Trash a message. If a trash folder is configured and the message is
     *  not already in it, this is a move-to-trash. If the message is already
     *  in trash (or no trash exists), it's a hard delete + body unlink.
     *
     *  Returns "moved-to-trash" or "expunged" so the caller knows whether
     *  to enqueue an IMAP MOVE or a DELETE+EXPUNGE on the queue.
     */
    trashMessage(accountId: string, uid: number, folderId: number, trashFolderId: number | null): "moved-to-trash" | "expunged" {
        const env: any = this.db.getMessageByUid(accountId, uid, folderId);
        if (env?.messageId) this.db.addTombstone(accountId, env.messageId, env.subject || "");
        const msgUuid: string | undefined = env?.uuid;
        if (trashFolderId != null && trashFolderId !== folderId) {
            this.db.moveMessageLocal(accountId, uid, folderId, trashFolderId);
            this.db.recalcFolderCounts(folderId);
            this.db.recalcFolderCounts(trashFolderId);
            if (msgUuid) {
                this.bus.publish({
                    topic: `message:${msgUuid}`,
                    kind: "messageMoved",
                    accountId, folderId, targetFolderId: trashFolderId, uid, msgUuid,
                });
            }
            this.bus.publish({
                topic: `folder:${trashFolderId}`,
                kind: "folderCountsChanged",
                accountId, folderId: trashFolderId,
            });
            return "moved-to-trash";
        }
        this.db.deleteMessage(accountId, folderId, uid, "user-initiated trash (already in trash → expunge)", "Store.trashMessage");
        this.db.recalcFolderCounts(folderId);
        if (msgUuid) {
            this.bus.publish({
                topic: `message:${msgUuid}`,
                kind: "messageRemoved",
                accountId, folderId, uid, msgUuid,
            });
        }
        return "expunged";
    }

    /** Restore a message from trash back to its original folder. Local-only;
     *  caller handles the queue (cancel-pending-MOVE vs queue-counter-MOVE).
     *  Returns true if a local row was moved. */
    undeleteMessage(accountId: string, uid: number, trashFolderId: number, originalFolderId: number): boolean {
        const moved = this.db.moveMessageLocal(accountId, uid, trashFolderId, originalFolderId);
        if (!moved) return false;
        this.db.recalcFolderCounts(trashFolderId);
        this.db.recalcFolderCounts(originalFolderId);
        const env: any = this.db.getMessageByUid(accountId, uid, originalFolderId);
        const msgUuid: string | undefined = env?.uuid;
        if (msgUuid) {
            this.bus.publish({
                topic: `message:${msgUuid}`,
                kind: "messageMoved",
                accountId, folderId: trashFolderId, targetFolderId: originalFolderId, uid, msgUuid,
            });
        }
        this.bus.publish({
            topic: `folder:${originalFolderId}`,
            kind: "folderCountsChanged",
            accountId, folderId: originalFolderId,
        });
        return true;
    }
}
