/**
 * Web-compatible MailxService for Android/browser.
 * Replaces @bobfrankston/mailx-service which depends on Node.js (fs, dns, mailparser).
 *
 * Key differences from desktop:
 * - Uses WebMailxDB (wa-sqlite) instead of MailxDB (node:sqlite)
 * - Uses WebMessageStore (IndexedDB) instead of FileMessageStore (filesystem)
 * - Uses postal-mime or manual header parsing instead of mailparser's simpleParser
 * - Settings via IndexedDB + GDrive API instead of filesystem
 * - No dns.resolveMx — provider detection is static (Gmail/Outlook/Yahoo/iCloud)
 */
import { buildMimeMessage, sanitizeHtml, REMINDER_STATE_FILE, normalizeReminderState, mergeReminderStates, reminderStatesEqual } from "@bobfrankston/mailx-types";
import { loadSettings, saveSettings, loadAllowlist, updateAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, cloudRead, cloudWrite } from "./web-settings.js";
/** Parse an RFC 2822 message from raw bytes. Handles basic MIME. */
function parseEmailSource(raw) {
    const headers = new Map();
    const headerEnd = raw.indexOf("\r\n\r\n");
    const headerSection = headerEnd >= 0 ? raw.substring(0, headerEnd) : raw;
    const body = headerEnd >= 0 ? raw.substring(headerEnd + 4) : "";
    // Parse headers (handle continuations)
    const headerLines = headerSection.split("\r\n");
    let lastKey = "";
    for (const line of headerLines) {
        if (line.startsWith(" ") || line.startsWith("\t")) {
            // Continuation
            if (lastKey) {
                headers.set(lastKey, (headers.get(lastKey) || "") + " " + line.trim());
            }
        }
        else {
            const colon = line.indexOf(":");
            if (colon > 0) {
                lastKey = line.substring(0, colon).toLowerCase().trim();
                headers.set(lastKey, line.substring(colon + 1).trim());
            }
        }
    }
    const contentType = headers.get("content-type") || "text/plain";
    const transferEncoding = (headers.get("content-transfer-encoding") || "").toLowerCase();
    const attachments = [];
    // Check for multipart
    const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/i);
    if (boundaryMatch) {
        const boundary = boundaryMatch[1];
        return parseMimeParts(body, boundary, headers);
    }
    // Single part
    let decoded = decodeBody(body, transferEncoding);
    const isHtml = contentType.includes("text/html");
    return {
        html: isHtml ? decoded : "",
        text: isHtml ? "" : decoded,
        headers,
        attachments,
    };
}
function parseMimeParts(body, boundary, topHeaders) {
    const parts = body.split("--" + boundary);
    let html = "";
    let text = "";
    const attachments = [];
    for (let i = 1; i < parts.length; i++) {
        const part = parts[i];
        if (part.startsWith("--"))
            break; // End marker
        const partHeaderEnd = part.indexOf("\r\n\r\n");
        if (partHeaderEnd < 0)
            continue;
        const partHeaderSection = part.substring(0, partHeaderEnd);
        const partBody = part.substring(partHeaderEnd + 4).replace(/\r?\n$/, "");
        // Parse part headers
        const partHeaders = new Map();
        const partHeaderLines = partHeaderSection.split("\r\n");
        let lastKey = "";
        for (const line of partHeaderLines) {
            if (line.startsWith(" ") || line.startsWith("\t")) {
                if (lastKey)
                    partHeaders.set(lastKey, (partHeaders.get(lastKey) || "") + " " + line.trim());
            }
            else {
                const colon = line.indexOf(":");
                if (colon > 0) {
                    lastKey = line.substring(0, colon).toLowerCase().trim();
                    partHeaders.set(lastKey, line.substring(colon + 1).trim());
                }
            }
        }
        const partType = partHeaders.get("content-type") || "text/plain";
        const partEncoding = (partHeaders.get("content-transfer-encoding") || "").toLowerCase();
        const disposition = partHeaders.get("content-disposition") || "";
        // Nested multipart
        const nestedBoundary = partType.match(/boundary="?([^";\s]+)"?/i);
        if (nestedBoundary) {
            const nested = parseMimeParts(partBody, nestedBoundary[1], topHeaders);
            if (!html && nested.html)
                html = nested.html;
            if (!text && nested.text)
                text = nested.text;
            attachments.push(...nested.attachments);
            continue;
        }
        if (disposition.includes("attachment") || (partType.includes("application/") && !partType.includes("text/"))) {
            const filenameMatch = disposition.match(/filename="?([^";\r\n]+)"?/i) || partType.match(/name="?([^";\r\n]+)"?/i);
            const decoded = decodeBody(partBody, partEncoding);
            attachments.push({
                filename: filenameMatch?.[1]?.trim() || `attachment-${attachments.length}`,
                contentType: partType.split(";")[0].trim(),
                size: decoded.length,
                contentId: (partHeaders.get("content-id") || "").replace(/[<>]/g, ""),
                content: new TextEncoder().encode(decoded),
            });
        }
        else if (partType.includes("text/html")) {
            const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i);
            html = decodeBody(partBody, partEncoding, charsetMatch?.[1] || "utf-8");
        }
        else if (partType.includes("text/plain")) {
            const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i);
            text = decodeBody(partBody, partEncoding, charsetMatch?.[1] || "utf-8");
        }
    }
    return { html, text, headers: topHeaders, attachments };
}
function decodeBody(body, encoding, charset = "utf-8") {
    // Step 1: decode the transfer encoding to a byte array
    let bytes;
    if (encoding === "base64") {
        try {
            const binary = atob(body.replace(/\s/g, ""));
            bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
        }
        catch {
            return body;
        }
    }
    else if (encoding === "quoted-printable") {
        // Decode QP into bytes (NOT into a string — multi-byte UTF-8 must stay as bytes)
        const cleaned = body.replace(/=\r?\n/g, "");
        const out = [];
        for (let i = 0; i < cleaned.length; i++) {
            const c = cleaned[i];
            if (c === "=" && i + 2 < cleaned.length && /[0-9A-Fa-f]{2}/.test(cleaned.substr(i + 1, 2))) {
                out.push(parseInt(cleaned.substr(i + 1, 2), 16));
                i += 2;
            }
            else {
                // Existing character — encode as its byte (assumes ASCII for QP source)
                out.push(c.charCodeAt(0) & 0xff);
            }
        }
        bytes = new Uint8Array(out);
    }
    else if (encoding === "7bit" || encoding === "8bit" || encoding === "" || encoding === "binary") {
        // No transfer encoding — body is already a string of single-byte chars
        bytes = Uint8Array.from(body, c => c.charCodeAt(0) & 0xff);
    }
    else {
        // Unknown encoding — return as-is
        return body;
    }
    // Step 2: decode bytes using the declared charset (default UTF-8)
    try {
        const normalized = charset.toLowerCase().replace("windows-", "windows-").replace("iso-", "iso-");
        return new TextDecoder(normalized).decode(bytes);
    }
    catch {
        // Unknown charset — fall back to UTF-8 with replacement chars
        return new TextDecoder("utf-8").decode(bytes);
    }
}
// ── Service ──
export class WebMailxService {
    db;
    bodyStore;
    syncManager;
    constructor(db, bodyStore, syncManager) {
        this.db = db;
        this.bodyStore = bodyStore;
        this.syncManager = syncManager;
    }
    // ── Accounts ──
    async getAccounts() {
        const dbAccounts = this.db.getAccounts();
        const settings = await loadSettings();
        const ordered = [];
        for (const cfg of settings.accounts) {
            const a = dbAccounts.find(d => d.id === cfg.id);
            // Merge the FULL shared (Drive) config over the local DB row, not just
            // label/defaultSend. The old code cherry-picked two fields and dropped
            // everything else — notably `identityDomains` — so the phone only knew
            // `["bob.ma"]` (derived from the email domain) while desktop had all
            // three domains, and reply auto-From for bobfrankston.com/frankston.com
            // aliases fell back to the default From (Bob 2026-06-19). The Drive
            // config is authoritative for config fields; the DB row keeps its
            // sync-state fields (last_sync etc., which cfg doesn't carry).
            if (a)
                ordered.push({ ...a, ...cfg });
        }
        for (const a of dbAccounts) {
            if (!ordered.find((o) => o.id === a.id))
                ordered.push(a);
        }
        return ordered;
    }
    // ── Folders ──
    getFolders(accountId) {
        return this.db.getFolders(accountId);
    }
    // ── Messages ──
    getUnifiedInbox(page = 1, pageSize = 50) {
        return this.db.getUnifiedInbox(page, pageSize);
    }
    getMessages(accountId, folderId, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search) {
        return this.db.getMessages({ accountId, folderId, page, pageSize, sort: sort, sortDir: sortDir, search });
    }
    async getMessage(accountId, uid, allowRemote = false, folderId) {
        const envelope = this.db.getMessageByUid(accountId, uid, folderId);
        if (!envelope)
            throw new Error("Message not found");
        let bodyHtml = "";
        let bodyText = "";
        let hasRemoteContent = false;
        let attachments = [];
        let deliveredTo = "", returnPath = "", listUnsubscribe = "";
        let raw = null;
        try {
            raw = await this.syncManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
        }
        catch (fetchErr) {
            // Mirror the desktop service: surface as structured bodyError
            // so the viewer shows its dedicated error banner instead of
            // rendering the message text verbatim in the body area.
            const rawErr = fetchErr.message || "fetch failed";
            // "Deleted on the server" is claimed ONLY on positive server
            // evidence (the server said the UID doesn't exist). Everything
            // else — network, rate limits, and especially app bugs like a
            // ReferenceError from a mis-initialized bridge — is retryable.
            // The old logic defaulted UNKNOWN errors to permanent, so
            // "msgapi is not defined" rendered as "message may have been
            // deleted" (Bob 2026-07-12).
            const serverSaysGone = /no such message|not found|does not exist|nonexistent|expunged|invalid uid/i.test(rawErr);
            return {
                ...envelope, bodyHtml: "", bodyText: "",
                bodyError: rawErr, bodyErrorTransient: !serverSaysGone,
                hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: ""
            };
        }
        if (!raw) {
            return {
                ...envelope, bodyHtml: "", bodyText: "",
                bodyError: "Message body not cached locally and the server fetch returned nothing.",
                bodyErrorTransient: true,
                hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: ""
            };
        }
        else {
            const source = new TextDecoder().decode(raw);
            const parsed = parseEmailSource(source);
            bodyHtml = parsed.html || "";
            bodyText = parsed.text || "";
            attachments = (parsed.attachments || []).map((a, i) => ({
                id: i,
                filename: a.filename || `attachment-${i}`,
                mimeType: a.contentType || "application/octet-stream",
                size: a.size || 0,
                contentId: a.contentId || ""
            }));
            // Header extraction — parity with desktop store.ts. The web parser
            // hardcoded these to "", so on Android msg.deliveredTo was always
            // empty and reply auto-From couldn't pick the alias the message was
            // delivered to (Bob 2026-06-18 "android reply doesn't handle the
            // delivered-to"). FIRST Delivered-To = final delivery (agents prepend).
            const hEnd = source.search(/\r?\n\r?\n/);
            const hb = hEnd >= 0 ? source.slice(0, hEnd) : source;
            const unfolded = [];
            for (const ln of hb.split(/\r?\n/)) {
                if (/^[ \t]/.test(ln) && unfolded.length)
                    unfolded[unfolded.length - 1] += " " + ln.trim();
                else
                    unfolded.push(ln);
            }
            const firstHeader = (name) => {
                const re = new RegExp("^" + name + ":\\s*(.*)$", "i");
                for (const ln of unfolded) {
                    const m = ln.match(re);
                    if (m)
                        return m[1].trim();
                }
                return "";
            };
            deliveredTo = firstHeader("Delivered-To");
            returnPath = firstHeader("Return-Path").replace(/[<>]/g, "");
            listUnsubscribe = firstHeader("List-Unsubscribe");
        }
        // Sanitize HTML + compute flagged-sender state. flaggedSenders /
        // flaggedDomains live in the same allowlist.jsonc as remote-content
        // permissions; matching this on phone parity-fixes the viewer's
        // ⚠ FLAGGED banner (which only painted on desktop before).
        const allowList = await loadAllowlist();
        const senderAddr = (envelope.from?.address || "").toLowerCase();
        const senderDomain = senderAddr.split("@")[1] || "";
        const isFlagged = !!((allowList.flaggedSenders || []).some((s) => (s || "").toLowerCase() === senderAddr) ||
            (allowList.flaggedDomains || []).some((d) => (d || "").toLowerCase() === senderDomain));
        if (bodyHtml && !allowRemote) {
            const toAddrs = (envelope.to || []).map((a) => a.address);
            const isAllowed = allowList.senders.includes(senderAddr) ||
                allowList.domains.includes(senderDomain) ||
                toAddrs.some((a) => allowList.recipients?.includes(a));
            if (isAllowed) {
                allowRemote = true;
            }
            else {
                const result = sanitizeHtml(bodyHtml);
                bodyHtml = result.html;
                hasRemoteContent = result.hasRemoteContent;
            }
        }
        return {
            ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
            attachments, deliveredTo, returnPath, listUnsubscribe,
            isFlagged,
        };
    }
    /** Flag (or unflag) a sender / domain. Mirrors the desktop service so
     *  the viewer's right-click "Flag sender" / "Flag domain" buttons work
     *  on phone — was a no-op on Android before. Toggles membership in
     *  flaggedSenders / flaggedDomains in allowlist.jsonc, which syncs
     *  back to the cloud copy. */
    async flagSenderOrDomain(type, value) {
        const v = (value || "").trim().toLowerCase();
        if (!v)
            return { flagged: false };
        // RMW via updateAllowlist — the mutation applies to the FRESH cloud
        // copy, never a stale/default cached base (the 2026-07-23 wipe).
        let flagged = false;
        await updateAllowlist((list) => {
            const key = type === "sender" ? "flaggedSenders" : "flaggedDomains";
            const arr = Array.isArray(list[key]) ? list[key] : [];
            const idx = arr.findIndex((x) => (x || "").toLowerCase() === v);
            if (idx >= 0) {
                arr.splice(idx, 1);
                flagged = false;
            }
            else {
                arr.push(v);
                flagged = true;
            }
            list[key] = arr;
            return list;
        });
        return { flagged };
    }
    async updateFlags(accountId, uid, flags) {
        const envelope = this.db.getMessageByUid(accountId, uid);
        await this.syncManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags);
    }
    // ── Remote content allow-list ──
    async allowRemoteContent(type, value) {
        // RMW via updateAllowlist — see flagSenderOrDomain.
        await updateAllowlist((list) => {
            if (type === "sender" && !list.senders.includes(value))
                list.senders.push(value);
            else if (type === "domain" && !list.domains.includes(value))
                list.domains.push(value);
            else if (type === "recipient") {
                if (!list.recipients)
                    list.recipients = [];
                if (!list.recipients.includes(value))
                    list.recipients.push(value);
            }
            return list;
        });
    }
    // ── Search ──
    async search(q, page = 1, pageSize = 50, scope = "all", accountId, folderId) {
        if (!q.trim())
            return { items: [], total: 0, page, pageSize };
        // On mobile, always use local search (no server-side IMAP search)
        if (scope === "current" && accountId && folderId) {
            return this.db.searchMessages(q, page, pageSize, accountId, folderId);
        }
        return this.db.searchMessages(q, page, pageSize);
    }
    // ── Sync ──
    getSyncPending() {
        return { pending: this.db.getTotalPendingSyncCount() };
    }
    async syncAll() {
        await this.syncManager.syncAll();
    }
    async syncAccount(accountId) {
        const folders = await this.syncManager.syncFolders(accountId);
        // INBOX-first: await INBOX so the UI re-renders new mail immediately,
        // then fire the rest in the background so labels don't block the
        // list. S57 (Android parity with desktop's local-first rule).
        const inbox = folders.find(f => f.specialUse === "inbox");
        const others = folders.filter(f => f.specialUse !== "inbox");
        if (inbox) {
            try {
                await this.syncManager.syncFolder(accountId, inbox.id);
            }
            catch (e) {
                console.error(`  Skipping INBOX ${inbox.path}: ${e.message}`);
            }
        }
        // Background fan-out: don't await; errors log. UI already has INBOX.
        (async () => {
            for (const folder of others) {
                try {
                    await this.syncManager.syncFolder(accountId, folder.id);
                }
                catch (e) {
                    console.error(`  Skipping folder ${folder.path}: ${e.message}`);
                }
            }
        })().catch(() => { });
    }
    async reauthenticate(accountId) {
        return this.syncManager.reauthenticate(accountId);
    }
    // ── Send ──
    async send(msg) {
        console.log(`[send] from=${msg?.from} to=${(msg?.to || []).length} subject="${msg?.subject || ""}" attachments=${Array.isArray(msg?.attachments) ? msg.attachments.length : 0}`);
        const settings = await loadSettings();
        const account = settings.accounts.find(a => a.id === msg.from);
        if (!account)
            throw new Error(`Unknown account: ${msg.from}`);
        const fromHeader = msg.fromAddress || `${account.name} <${account.email}>`;
        const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
        const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
        const bcc = msg.bcc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
        // Same assembler as desktop (mailx-types buildMimeMessage). This path
        // previously interpolated From/To/Cc/Bcc RAW — only Subject got RFC 2047
        // in the 2026-08-08 fix — so an accented display name sent from Android
        // went out as an EAI message, which receivers without SMTPUTF8 must
        // bounce. Sharing the assembler is what makes that drift impossible;
        // another hand-applied patch would not have.
        const built = buildMimeMessage({
            from: fromHeader, to, cc, bcc,
            subject: msg.subject,
            bodyHtml: msg.bodyHtml,
            bodyText: msg.bodyText,
            attachments: msg.attachments,
            inReplyTo: msg.inReplyTo,
            references: msg.references,
            domain: account.email.split("@")[1] || "mailx.local",
        });
        const rawMessage = built.raw;
        // queueOutgoingLocal on the Android bridge is async (it flushes
        // sql.js → IndexedDB before returning so a tab-close in the
        // debounce window can't lose the row). On the web-worker
        // SyncManager it's synchronous and returns void; awaiting an
        // undefined value is benign, so this works for both.
        await this.syncManager.queueOutgoingLocal(account.id, rawMessage);
        for (const addr of msg.to)
            this.db.recordSentAddress(addr.name, addr.address);
        if (msg.cc)
            for (const addr of msg.cc)
                this.db.recordSentAddress(addr.name, addr.address);
        if (msg.bcc)
            for (const addr of msg.bcc)
                this.db.recordSentAddress(addr.name, addr.address);
    }
    // ── Delete / Move ──
    async deleteMessage(accountId, uid) {
        const envelope = this.db.getMessageByUid(accountId, uid);
        if (!envelope)
            throw new Error("Message not found");
        await this.syncManager.trashMessage(accountId, envelope.folderId, envelope.uid);
    }
    async deleteMessages(accountId, uids) {
        const messages = uids.map(uid => {
            const env = this.db.getMessageByUid(accountId, uid);
            if (!env)
                return null;
            return { uid: env.uid, folderId: env.folderId };
        }).filter(m => m !== null);
        await this.syncManager.trashMessages(accountId, messages);
    }
    async moveMessage(accountId, uid, targetFolderId, targetAccountId) {
        const envelope = this.db.getMessageByUid(accountId, uid);
        if (!envelope)
            throw new Error("Message not found");
        if (targetAccountId && targetAccountId !== accountId) {
            await this.syncManager.moveMessageCrossAccount(accountId, envelope.uid, envelope.folderId, targetAccountId, targetFolderId);
        }
        else {
            await this.syncManager.moveMessage(accountId, envelope.uid, envelope.folderId, targetFolderId);
        }
    }
    async moveMessages(accountId, uids, targetFolderId) {
        const messages = uids.map(uid => {
            const env = this.db.getMessageByUid(accountId, uid);
            if (!env)
                return null;
            return { uid: env.uid, folderId: env.folderId };
        }).filter(m => m !== null);
        await this.syncManager.moveMessages(accountId, messages, targetFolderId);
    }
    async undeleteMessage(accountId, uid, folderId) {
        await this.syncManager.undeleteMessage(accountId, uid, folderId);
    }
    // ── Drafts ──
    async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId, bcc, from) {
        const settings = await loadSettings();
        const account = settings.accounts.find(a => a.id === accountId);
        if (!account)
            throw new Error(`Unknown account: ${accountId}`);
        const id = draftId || `mailx-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
        const raw = buildMimeMessage({
            from: from?.trim() || `${account.name} <${account.email}>`,
            to, cc, bcc,
            subject: subject || "(no subject)",
            bodyHtml, bodyText,
            domain: account.email.split("@")[1] || "mailx.local",
            extraHeaders: [`X-Mailx-Draft-ID: ${id}`],
            omitMessageId: true,
            simpleHtml: true,
        }).raw;
        const draftUid = await this.syncManager.saveDraft(accountId, raw, previousDraftUid, id);
        // Was `{ uid, draftId }` — silent drift from desktop's `{ draftUid, draftId }`
        // contract. Clients reading `data.draftUid` got undefined on Android,
        // breaking dedup on the next save.
        return { draftUid, draftId: id };
    }
    async deleteDraft(accountId, draftUid) {
        await this.syncManager.deleteDraft(accountId, draftUid);
    }
    // ── Contacts ──
    searchContacts(query) {
        query = (query || "").trim();
        if (query.length < 1)
            return [];
        // Fire-and-forget freshness kick — NEVER awaited (local-first: this
        // is the keystroke → suggestions path). A denylist edit made on
        // desktop applies to the next keystrokes once the reload lands.
        void this.maybeRefreshContactsConfig().catch(() => { });
        return this.db.searchContacts(query);
    }
    /** Re-pull contacts.jsonc only when the Drive copy actually changed.
     *  One metadata request (cloudStat) throttled to a few minutes; the
     *  full download + reload runs only on a new modifiedTime. Kicked from
     *  autocomplete, the periodic sync tick, and app-resume. */
    _contactsStatAt = 0;
    _contactsModifiedTime = null;
    static CONTACTS_STAT_MS = 3 * 60 * 1000;
    async maybeRefreshContactsConfig() {
        const now = Date.now();
        if (now - this._contactsStatAt < WebMailxService.CONTACTS_STAT_MS)
            return;
        this._contactsStatAt = now;
        const { cloudStat } = await import("./web-settings.js");
        const stat = await cloudStat("contacts.jsonc");
        if (!stat || stat.modifiedTime === this._contactsModifiedTime)
            return;
        this._contactsModifiedTime = stat.modifiedTime;
        console.log(`[contacts] contacts.jsonc changed on Drive (${stat.modifiedTime}) — reloading`);
        await this.loadContactsConfig();
    }
    /** Address-book listing — paginated, filterable. Mirrors mailx-service's
     *  signature so the same client-side address-book modal works on Android
     *  without an "ipc(...).listContacts is not a function" crash. */
    listContacts(query, page = 1, pageSize = 100) {
        return this.db.listContacts(query || "", page, pageSize);
    }
    /** Manual upsert from the address-book UI. The desktop path queues a
     *  Google People sync; Android relies on the desktop pushing changes
     *  back, so this is local-only for now. */
    upsertContact(name, email) {
        this.db.upsertContact(name || "", email);
        return { ok: true };
    }
    deleteContact(email) {
        this.db.deleteContactLocal(email);
        return { ok: true };
    }
    addContact(name, email) {
        if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
            return false;
        this.db.recordSentAddress(name || "", email);
        return true;
    }
    /** Q49 heuristic mirror: true if the user has ever sent a message to
     *  `recipientEmail` that had a non-empty Cc field. Compose uses this to
     *  decide whether to auto-expand the Cc row on reply. */
    hasCcHistoryTo(recipientEmail) {
        return this.db.hasCcHistoryTo?.(recipientEmail) ?? false;
    }
    // ── Settings ──
    async getSettings() {
        return loadSettings();
    }
    async saveSettingsData(settings) {
        await saveSettings(settings);
    }
    getStorageInfo() {
        return getStorageInfo();
    }
    // ── Folder management (limited on mobile — read-only) ──
    markFolderRead(folderId) {
        this.db.markFolderRead(folderId);
    }
    // ── Autocomplete ──
    async getAutocompleteSettings() {
        return loadAutocomplete();
    }
    async saveAutocompleteSettings(settings) {
        await saveAutocomplete(settings);
    }
    async autocomplete(_req) {
        // Autocomplete disabled on mobile by default
        return { suggestion: "" };
    }
    // ── Reset ──
    async resetStore() {
        await this.db.resetStore();
        await this.bodyStore.clear();
        console.log("[service] Store reset complete");
    }
    // ── JSONC config editor (Android: GDrive only; no local config.jsonc) ──
    // Mirror of the desktop service so the in-app editor works on phone.
    // Android sandbox has no per-machine config.jsonc; selecting it returns
    // null (the editor displays "(file not available on this platform)").
    async readJsoncFile(name) {
        const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
        if (!WHITELIST.includes(name))
            throw new Error(`File not allowed: ${name}`);
        if (name === "config.jsonc")
            return null; // local-only on desktop; n/a on Android
        const { cloudRead } = await import("./web-settings.js");
        return await cloudRead(name);
    }
    async writeJsoncFile(name, content) {
        const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
        if (!WHITELIST.includes(name))
            throw new Error(`File not allowed: ${name}`);
        if (name === "config.jsonc")
            throw new Error("config.jsonc is local-only — not editable on Android");
        const { parse: parseJsonc } = await import("jsonc-parser");
        const errors = [];
        parseJsonc(content, errors, { allowTrailingComma: true });
        if (errors.length) {
            throw new Error(`JSONC parse error: ${errors.map(e => e.error).join(", ")}`);
        }
        const { cloudWrite } = await import("./web-settings.js");
        const ok = await cloudWrite(name, content);
        if (!ok)
            throw new Error(`Failed to save ${name} to Drive`);
    }
    async formatJsonc(content) {
        const { format, applyEdits } = await import("jsonc-parser");
        const edits = format(content, undefined, {
            tabSize: 2, insertSpaces: true, eol: "\n", insertFinalNewline: true,
        });
        return applyEdits(content, edits);
    }
    async readConfigHelp(_name) {
        // Help markdown isn't bundled in the Android assets — return empty
        // so the editor's help panel just shows "No help available".
        return "";
    }
    // ── Priority senders / domains (Android parity) ──
    // Cached in-memory; refreshed on every read since contacts.jsonc is
    // small (~hundreds of entries) and reads hit IndexedDB cache.
    /** Compute and return the priority sender / domain index from the
     *  current contacts.jsonc. Reads from cloud (or local cache) every
     *  call — small file, simpler than maintaining a long-lived cache. */
    async getPriorityLists() {
        const { cloudRead } = await import("./web-settings.js");
        const raw = await cloudRead("contacts.jsonc");
        if (!raw)
            return { senders: [], domains: [] };
        const cfg = parseJsoncLoose(raw);
        const senders = Array.isArray(cfg?.preferred)
            ? cfg.preferred.filter((e) => e?.priority === true && e.email).map((e) => e.email.toLowerCase())
            : [];
        const domains = Array.isArray(cfg?.priorityDomains)
            ? cfg.priorityDomains.map((d) => (d || "").toLowerCase()).filter(Boolean)
            : [];
        return { senders, domains };
    }
    async setPrioritySender(email, value, name) {
        const lower = (email || "").trim().toLowerCase();
        if (!lower)
            return;
        const { cloudRead, cloudWrite } = await import("./web-settings.js");
        const raw = await cloudRead("contacts.jsonc");
        const cfg = raw ? parseJsoncLoose(raw) || {} : {};
        if (!Array.isArray(cfg.preferred))
            cfg.preferred = [];
        const idx = cfg.preferred.findIndex((e) => (e?.email || "").toLowerCase() === lower);
        if (idx >= 0) {
            if (value)
                cfg.preferred[idx].priority = true;
            else
                delete cfg.preferred[idx].priority;
        }
        else if (value) {
            cfg.preferred.push({ name: name || "", email, priority: true });
        }
        await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
    }
    async setPriorityDomain(domain, value) {
        const lower = (domain || "").trim().toLowerCase();
        if (!lower)
            return;
        const { cloudRead, cloudWrite } = await import("./web-settings.js");
        const raw = await cloudRead("contacts.jsonc");
        const cfg = raw ? parseJsoncLoose(raw) || {} : {};
        if (!Array.isArray(cfg.priorityDomains))
            cfg.priorityDomains = [];
        const i = cfg.priorityDomains.findIndex((d) => (d || "").toLowerCase() === lower);
        if (value && i < 0)
            cfg.priorityDomains.push(lower);
        else if (!value && i >= 0)
            cfg.priorityDomains.splice(i, 1);
        await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
    }
    // ── MailxApi contract: name aliases ───────────────────────────────
    // The IPC dispatch names (`sendMessage`, `searchMessages`) differ from
    // the historical web method names (`send`, `search`). Thin aliases keep
    // the existing internal method bodies while satisfying `implements
    // MailxApi`. NEW methods should use the IPC name directly so this
    // section doesn't grow.
    sendMessage(msg) { return this.send(msg); }
    searchMessages(query, page, pageSize, scope, accountId, folderId, _includeTrashSpam) {
        // Android `search` lacks the includeTrashSpam flag — folder scoping
        // is enforced UI-side anyway. Drop the 7th argument silently.
        return this.search(query, page, pageSize, scope, accountId, folderId);
    }
    // ── MailxApi contract: explicit stubs for unimplemented Android features ─
    //
    // Every method below is in the contract but has no Android implementation
    // today. Each is declared explicitly so it's *visible* in this file —
    // before the contract was added, these were just absent and IPC calls
    // returned "parent bridge has no method X", silently swallowed by
    // `.catch()` in callers. Now the gap is in one auditable list.
    //
    // Implementation policy:
    //   - Pure read with no Android backing: return [] / {} / null / false.
    //     UI degrades gracefully; no crash.
    //   - Write that mutates state: throw `notImpl()` so a UI button that
    //     calls it surfaces an error rather than appearing to succeed.
    //   - OS-specific (Word, msger popups, OS file open): also `notImpl()`.
    //     These are declared optional `?:` in the contract so we technically
    //     could omit them entirely — explicit stubs are clearer.
    //
    // To wire one up: replace the stub body, run tsc, the contract enforces
    // the signature.
    notImpl(name) {
        throw new Error(`Not implemented on Android: ${name}`);
    }
    // Threads / attachments / message reads
    getThreadMessages(_accountId, _threadId) { return []; }
    async getMessageSource(_accountId, _uid, _folderId) { return this.notImpl("getMessageSource"); }
    async getAttachment(_accountId, _uid, _attachmentId, _folderId) { return this.notImpl("getAttachment"); }
    // Android opens attachments via the native bridge (_nativeBridge.openAttachment),
    // not this service path — getAttachment + base64 hand-off covers it.
    async openAttachment(_accountId, _uid, _attachmentId, _folderId) { return this.notImpl("openAttachment"); }
    // Spam / abuse
    /** Mark as spam = move to the account's \Junk folder, exactly as desktop's
     *  MailxService.markAsSpamMessages does. This was a notImpl() stub, which
     *  is worse than it sounds: the client optimistically removes the rows
     *  from the list BEFORE calling this, then only writes the rejection to
     *  the status bar. So on Android the spam button looked like it worked,
     *  nothing moved on the server, and the next sync brought every message
     *  straight back (Bob 2026-08-07: "attempts to delete spam but they keep
     *  coming back"). moveMessages was implemented all along — spam just never
     *  got wired to it. */
    async markAsSpamMessages(accountId, uids) {
        const target = this.db.getFolders(accountId).find((f) => f.specialUse === "junk");
        if (!target)
            throw new Error(`No \\Junk/\\Spam folder found for ${accountId}`);
        await this.moveMessages(accountId, uids, target.id);
        return { targetFolderId: target.id, moved: uids.length };
    }
    async recordSpamReport(_accountId, _uid, _folderId) { return this.notImpl("recordSpamReport"); }
    // Folder CRUD
    async createFolder(_accountId, _parentPath, _name) { this.notImpl("createFolder"); }
    async renameFolder(_accountId, _folderId, _newName) { this.notImpl("renameFolder"); }
    async deleteFolder(_accountId, _folderId) { this.notImpl("deleteFolder"); }
    async moveFolderToTrash(_accountId, _folderId) { this.notImpl("moveFolderToTrash"); }
    async emptyFolder(_accountId, _folderId) { this.notImpl("emptyFolder"); }
    // Outbox management
    getOutboxStatus() { return { count: 0, items: [] }; }
    listQueuedOutgoing() { return []; }
    cancelQueuedOutgoing(_filePath) { return { ok: true }; }
    // Sync infrastructure — getSyncPending already defined above
    async drainStoreSync() { }
    reauthGoogleScopes() { return { cleared: 0 }; }
    /** No per-folder sync lane on Android — a folder "sync now" refreshes the
     *  whole account (cheap: Gmail incremental sync / one IMAP pass). */
    async syncFolderNow(accountId, _folderId) {
        await this.syncAccount(accountId);
    }
    cancelServerSearch() { }
    async copyMessages(_accountId, _uids, _folderIds, _targetFolderId) { this.notImpl("copyMessages"); }
    /** `{}` = play the built-in chime — no custom sound files on Android. */
    async getReminderSound() { return {}; }
    // Contacts extensions. The contacts.jsonc mutation logic is shared with
    // the desktop service — see mailx-types/contacts-config.ts. These used
    // to be `notImpl()` stubs, so the phone's ★ / ⊘ autocomplete buttons did
    // nothing; now they run the same code desktop does.
    async addPreferredContact(entry) {
        const { cloudRead, cloudWrite } = await import("./web-settings.js");
        const { addContactsPreferredEntry } = await import("@bobfrankston/mailx-types");
        await addContactsPreferredEntry(entry, cloudRead, cloudWrite);
        await this.loadContactsConfig();
    }
    async addToDenylist(email) {
        const { cloudRead, cloudWrite } = await import("./web-settings.js");
        const { addContactsDenylistEntry } = await import("@bobfrankston/mailx-types");
        await addContactsDenylistEntry(email, cloudRead, cloudWrite);
        await this.loadContactsConfig();
    }
    hasBccHistoryTo(_email) { return false; }
    /** Pull the shared GDrive `contacts.jsonc` and merge its entries into the
     *  local contacts table. Desktop flushes the full union there (everything
     *  it discovered from its mailbox corpus + Google contacts + preferred);
     *  this is how an Android device gets contacts it never saw in its own
     *  (partial) on-device corpus. Called at startup AND on a periodic tick /
     *  app-resume / after a ⊘ so a denylist edit made on another device
     *  reaches a running phone (Bob 2026-07-07: GDrive edit was still being
     *  offered). The denylist refresh runs every call; the contact IMPORT
     *  runs once per session — recordSentAddress bumps `use_count`, so
     *  re-importing on every reload would inflate autocomplete rank. */
    _contactsImported = false;
    async loadContactsConfig() {
        try {
            const { cloudRead } = await import("./web-settings.js");
            const raw = await cloudRead("contacts.jsonc");
            if (!raw)
                return null;
            const cfg = parseJsoncLoose(raw);
            if (!cfg)
                return null;
            // Apply the denylist so denylisted addresses are filtered out of
            // autocomplete — without this the ⊘ button writes the cloud file
            // but the phone keeps suggesting the address.
            this.db.setContactsDenylist(Array.isArray(cfg.denylist) ? cfg.denylist : []);
            this.db.setContactsDenyPatterns(Array.isArray(cfg.denylistPatterns) ? cfg.denylistPatterns : []);
            if (this._contactsImported)
                return { imported: 0 };
            this._contactsImported = true;
            let imported = 0;
            for (const list of [cfg.preferred, cfg.discovered]) {
                if (!Array.isArray(list))
                    continue;
                for (const e of list) {
                    const email = (e?.email || "").trim();
                    if (!email)
                        continue;
                    this.db.recordSentAddress((e?.name || "").trim(), email);
                    imported++;
                }
            }
            if (imported > 0)
                console.log(`[contacts] loaded ${imported} from contacts.jsonc`);
            return { imported };
        }
        catch (e) {
            console.error(`[contacts] loadContactsConfig failed: ${e?.message || e}`);
            return null;
        }
    }
    // Calendar — Android UI has its own native calendar; no JS-side data.
    async getCalendarEvents(_fromMs, _toMs) { return []; }
    async getCalendars() { return []; }
    async createCalendarEvent(_ev) { return this.notImpl("createCalendarEvent"); }
    async updateCalendarEvent(_uuid, _patch) { return this.notImpl("updateCalendarEvent"); }
    async deleteCalendarEvent(_uuid) { return this.notImpl("deleteCalendarEvent"); }
    // Tasks — same story
    async getTasks(_includeCompleted) { return []; }
    async createTask(_t) { return this.notImpl("createTask"); }
    async updateTask(_uuid, _patch) { return this.notImpl("updateTask"); }
    async deleteTask(_uuid) { return this.notImpl("deleteTask"); }
    // Reminder dismissed/snoozed state — same reminders.jsonc on the shared
    // Drive folder the desktop uses, same mailx-types merge. Alarms don't
    // fire on Android today (getCalendarEvents/getTasks return []), but the
    // API is live so this device still contributes its state and adopts the
    // desktops' — the moment calendar lands here, sync just works.
    reminderStateCache = null;
    async getReminderState() {
        if (this.reminderStateCache && Date.now() - this.reminderStateCache.at < 60_000) {
            return this.reminderStateCache.state;
        }
        const now = Date.now();
        let state = { dismissed: {}, snoozed: {} };
        try {
            const json = await cloudRead(REMINDER_STATE_FILE);
            if (json != null)
                state = normalizeReminderState(JSON.parse(json), now);
        }
        catch { /* offline — empty state; localStorage still rules locally */ }
        this.reminderStateCache = { state, at: Date.now() };
        return state;
    }
    async mergeReminderState(patch) {
        const now = Date.now();
        const local = normalizeReminderState(patch, now);
        let cloud = { dismissed: {}, snoozed: {} };
        let hadCloud = false;
        try {
            const json = await cloudRead(REMINDER_STATE_FILE);
            if (json != null) {
                cloud = normalizeReminderState(JSON.parse(json), now);
                hadCloud = true;
            }
        }
        catch { /* offline */ }
        const merged = mergeReminderStates(cloud, local, now);
        if (!(hadCloud && reminderStatesEqual(merged, cloud))) {
            try {
                await cloudWrite(REMINDER_STATE_FILE, JSON.stringify(merged, null, 2) + "\n");
            }
            catch (e) {
                console.error(`[reminders] cloud write failed: ${e?.message || e}`);
            }
        }
        this.reminderStateCache = { state: merged, at: Date.now() };
        return merged;
    }
    // User dictionary (cloud-mirrored) — Android can implement these via
    // the same userdict.csv cloud round-trip the desktop uses; until that
    // wire-up lands, return safe defaults so spellcheck.ts catch() paths
    // don't fire and clutter logs.
    async getUserDict() { return []; }
    async addUserDictWord(_word) { return []; }
    async addUserDictWords(_words) { return []; }
    async removeUserDictWord(_word) { return []; }
    // Diagnostics / version / primary account
    getDiagnostics() { return { ok: true, platform: "android" }; }
    getPrimaryAccount(_feature) { return null; }
    getVersion() { return { version: "android", platform: "android" }; }
    consumePendingMailto() { return null; }
    logClientEvent(..._args) { }
    // Setup / repair / unsubscribe
    async setupAccount(_name, _email, _password) { return this.notImpl("setupAccount"); }
    async repairAccounts() { return this.notImpl("repairAccounts"); }
    async unsubscribeOneClick(_url) { return this.notImpl("unsubscribeOneClick"); }
    // AI transforms — pipe through to web autocomplete or no-op
    async aiTransform(_req) { return this.notImpl("aiTransform"); }
}
/** Loose JSONC parse — strips // comments and trailing commas before JSON.parse.
 *  Sufficient for contacts.jsonc which is machine-written; doesn't pull in
 *  jsonc-parser as a dep on the Android side where bundle size matters. */
function parseJsoncLoose(raw) {
    try {
        const stripped = raw
            .replace(/^\s*\/\/.*$/gm, "")
            .replace(/,(\s*[}\]])/g, "$1");
        return JSON.parse(stripped);
    }
    catch {
        return null;
    }
}
//# sourceMappingURL=web-service.js.map