/**
 * 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 { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo } 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);
            if (a)
                ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false });
        }
        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 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";
            const isTransient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(rawErr);
            return {
                ...envelope, bodyHtml: "", bodyText: "",
                bodyError: rawErr, bodyErrorTransient: isTransient,
                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 || ""
            }));
        }
        // Sanitize HTML
        if (bodyHtml && !allowRemote) {
            const allowList = await loadAllowlist();
            const senderAddr = envelope.from?.address || "";
            const senderDomain = senderAddr.split("@")[1] || "";
            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: "",
        };
    }
    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) {
        const list = await loadAllowlist();
        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);
        }
        await saveAllowlist(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) {
        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(", ");
        // HTML-bodied send gets a multipart/alternative wrapper with a
        // text/plain derived from the HTML — matches desktop's path; spam
        // filters score HTML-only mail harshly without it.
        const hasHtml = !!msg.bodyHtml;
        const htmlBody = msg.bodyHtml || "";
        const textBody = msg.bodyText || (hasHtml ? htmlToPlainText(htmlBody) : "");
        const htmlEncoded = hasHtml ? encodeQuotedPrintable(htmlBody) : "";
        const textEncoded = encodeQuotedPrintable(textBody);
        const domain = account.email.split("@")[1] || "mailx.local";
        const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
        const envelope = [
            `From: ${fromHeader}`, `To: ${to}`,
            cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
            `Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
            `Message-ID: ${messageId}`,
            msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
            msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
            `MIME-Version: 1.0`,
        ].filter(h => h !== null);
        let rawMessage;
        if (hasHtml) {
            const altBoundary = `mailx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
            const body = `--${altBoundary}\r\n` +
                `Content-Type: text/plain; charset=UTF-8\r\n` +
                `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
                `${textEncoded}\r\n` +
                `--${altBoundary}\r\n` +
                `Content-Type: text/html; charset=UTF-8\r\n` +
                `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
                `${htmlEncoded}\r\n` +
                `--${altBoundary}--\r\n`;
            const headers = [
                ...envelope,
                `Content-Type: multipart/alternative; boundary="${altBoundary}"`,
            ].join("\r\n");
            rawMessage = `${headers}\r\n\r\n${body}`;
        }
        else {
            const headers = [
                ...envelope,
                `Content-Type: text/plain; charset=UTF-8`,
                `Content-Transfer-Encoding: quoted-printable`,
            ].join("\r\n");
            rawMessage = `${headers}\r\n\r\n${textEncoded}`;
        }
        // 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) {
        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 body = bodyHtml || bodyText || "";
        const bodyEncoded = encodeQuotedPrintable(body);
        const headers = [
            `From: ${account.name} <${account.email}>`,
            to ? `To: ${to}` : null, cc ? `Cc: ${cc}` : null,
            `Subject: ${subject || "(no subject)"}`, `Date: ${new Date().toUTCString()}`,
            `X-Mailx-Draft-ID: ${id}`,
            `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
        ].filter(h => h !== null).join("\r\n");
        const raw = `${headers}\r\n\r\n${bodyEncoded}`;
        const uid = await this.syncManager.saveDraft(accountId, raw, previousDraftUid, id);
        return { uid, draftId: id };
    }
    async deleteDraft(accountId, draftUid) {
        await this.syncManager.deleteDraft(accountId, draftUid);
    }
    // ── Contacts ──
    searchContacts(query) {
        query = (query || "").trim();
        if (query.length < 1)
            return [];
        return this.db.searchContacts(query);
    }
    /** 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");
    }
}
//# sourceMappingURL=web-service.js.map