/**
 * Compose window entry point.
 * Opened as a popup from the main mailx window.
 * Receives init data via window.opener.postMessage or URL params.
 */
import { createEditor } from "./editor.js";
import { getSettings, getAccounts, searchContacts, saveDraft as apiSaveDraft, deleteDraft, logClientEvent, addPreferredContact, addToDenylist, openInWord, closeWordEdit, onEvent } from "../lib/api-client.js";
import { showContextMenu } from "../components/context-menu.js";
// Very first line the iframe runs — if this doesn't reach Node, the iframe
// itself isn't loading or the bridge is completely broken.
logClientEvent("compose-module-loaded", { href: location.href, version: window.mailxVersion || "?" });
// Per-step timing — Ctrl+N → compose-visible has been a perceived-latency
// problem; the `[compose-tick]` log lines isolate which stage actually
// costs the time. `t0` is iframe-module-execution-start; each tick adds
// the delta plus a stage label.
const _composeT0 = performance.now();
function _ctick(label) {
    const ms = (performance.now() - _composeT0).toFixed(0).padStart(5);
    try {
        logClientEvent(`compose-tick ${ms}ms ${label}`);
    }
    catch { /* */ }
}
_ctick("module body executing");
/** Close compose window */
function closeCompose() {
    logClientEvent("compose-close");
    // S61: Android WebView's window.close() override is unreliable inside
    // iframes — compose overlay sometimes stays visible after Send. Primary
    // path is a parent postMessage; window.close() is a fallback that also
    // works on desktop/msger where the override DOES fire reliably.
    try {
        parent.postMessage({ type: "mailx-compose-close" }, "*");
    }
    catch { /* */ }
    try {
        window.close();
    }
    catch { /* */ }
}
// ── Load editor scripts dynamically ──
function loadScript(src) {
    return new Promise((resolve, reject) => {
        const s = document.createElement("script");
        s.src = src;
        s.onload = () => resolve();
        s.onerror = () => reject(new Error(`Failed to load ${src}`));
        document.head.appendChild(s);
    });
}
function loadCSS(href) {
    const link = document.createElement("link");
    link.rel = "stylesheet";
    link.href = href;
    document.head.appendChild(link);
}
async function loadEditorAssets(type) {
    if (type === "tiptap") {
        // tiptap UMD bundles from CDN
        const cdn = "https://cdn.jsdelivr.net/npm";
        await loadScript(`${cdn}/@tiptap/core@2/dist/index.umd.js`);
        await Promise.all([
            loadScript(`${cdn}/@tiptap/starter-kit@2/dist/index.umd.js`),
            loadScript(`${cdn}/@tiptap/extension-link@2/dist/index.umd.js`),
            loadScript(`${cdn}/@tiptap/extension-image@2/dist/index.umd.js`),
            loadScript(`${cdn}/@tiptap/extension-underline@2/dist/index.umd.js`),
            loadScript(`${cdn}/@tiptap/extension-placeholder@2/dist/index.umd.js`),
        ]);
    }
    else {
        // Quill — bundled locally by bin/build-quill.js to client/lib/quill/.
        // Earlier versions loaded from jsdelivr CDN, which added 100-500 ms
        // (network-dependent) to every compose open. Relative paths resolve
        // against `client/compose/`, so `../lib/quill/...` reaches the copy.
        loadCSS("../lib/quill/quill.snow.css");
        await loadScript("../lib/quill/quill.js");
    }
}
let editorType = "quill";
let appSettings = null;
try {
    const cached = localStorage.getItem("mailx-editor-type");
    if (cached === "tiptap" || cached === "quill" || cached === "tinymce")
        editorType = cached;
}
catch { /* private-mode / SecurityError — default quill */ }
// Refresh the cache asynchronously — doesn't block compose open.
(async () => {
    try {
        appSettings = await getSettings();
        const settingValue = appSettings?.ui?.editor;
        const next = settingValue === "tiptap" ? "tiptap"
            : settingValue === "tinymce" ? "tinymce"
                : "quill";
        try {
            localStorage.setItem("mailx-editor-type", next);
        }
        catch { /* */ }
        // Note: we don't hot-swap the editor if the preference changed while
        // compose was opening — the old type is already instantiated. Next
        // compose open will pick up the new preference.
    }
    catch { /* non-fatal */ }
})();
// Editor init — try the preferred type first; if its CDN/setup fails,
// fall back to the OTHER editor before giving up on the toolbar entirely.
// Both Quill and tiptap fetch from jsdelivr; transient CDN issues used to
// drop the user into a plain-textarea fallback with no formatting controls.
// Now we keep trying until either an editor with a toolbar comes up, or
// both fail and we render the minimal contenteditable as last resort.
let editor;
const container = document.getElementById("compose-editor");
/** Update the small badge in the compose toolbar showing which editor
 *  is actually active (may differ from the user's setting if the
 *  preferred one failed to load). Helpful for diagnosing paste / format
 *  differences across Quill / tiptap / TinyMCE without diving into logs. */
function setActiveEditorBadge(type) {
    const el = document.getElementById("compose-editor-badge");
    if (!el)
        return;
    const labels = {
        quill: "Quill",
        tiptap: "tiptap",
        tinymce: "TinyMCE",
        fallback: "plain (fallback)",
    };
    const docs = {
        quill: "https://quilljs.com/docs/quickstart",
        tiptap: "https://tiptap.dev/docs/editor/introduction",
        tinymce: "https://www.tiny.cloud/docs/tinymce/6/",
        fallback: "https://github.com/BobFrankston/mailx/blob/master/app/docs/editor.md",
    };
    el.textContent = labels[type] || type;
    el.dataset.editor = type;
    const url = docs[type];
    if (url) {
        el.style.cursor = "pointer";
        el.title = `Open ${labels[type]} documentation`;
        el.onclick = () => {
            const api = window.mailxapi;
            if (api?.openExternal)
                api.openExternal(url);
            else
                window.open(url, "_blank", "noopener,noreferrer");
        };
    }
    else {
        el.style.cursor = "";
        el.title = "";
        el.onclick = null;
    }
}
async function tryEditor(type) {
    try {
        // tinymce loads itself via the rmf-tiny adapter (no jsdelivr asset).
        if (type !== "tinymce")
            await loadEditorAssets(type);
    }
    catch (e) {
        logClientEvent("compose-editor-assets-failed", { type, error: String(e?.message || e) });
        return null;
    }
    container.classList.remove("editor-tiptap", "editor-quill", "editor-tinymce");
    container.classList.add(`editor-${type}`);
    try {
        const ed = await createEditor(container, type);
        // TinyMCE: wire our JS spellchecker into the editor iframe so
        // right-click on a misspelled word offers suggestions + "Add to
        // dictionary." WebView2's built-in spellcheck doesn't reliably
        // engage on msger-hosted iframes (see msger main.rs around
        // IsSpellcheckEnabled). nspell + dictionary-en gives us the
        // suggestions UX without depending on the host. Done at this
        // layer (not inside rmf-tiny) so the spellcheck stays a mailx
        // concern; other apps embedding rmf-tiny stay clean.
        if (type === "tinymce" && ed && ed.nativeEditor) {
            const native = ed.nativeEditor;
            const attach = () => {
                import("./spellcheck.js").then(m => m.wireSpellcheck(native))
                    .catch(e => console.error("[compose] spellcheck wire failed:", e));
            };
            // TinyMCE's iframe exists immediately, but `getDoc()` returns
            // null until the editor's `init` event fires. Hook either path.
            if (native.initialized)
                attach();
            else
                native.on("init", attach);
        }
        return ed;
    }
    catch (e) {
        logClientEvent("compose-editor-create-failed", { type, error: String(e?.message || e) });
        return null;
    }
}
let activeEditorType = editorType;
_ctick(`editor load start (${editorType})`);
editor = (await tryEditor(editorType));
_ctick(`editor load end (${editorType}, ok=${!!editor})`);
if (!editor) {
    // Preferred editor failed — try the other one before giving up.
    // For tinymce: fall back to quill since the user opted into tinymce
    // explicitly; if it isn't installed, give them the working default.
    const fallbackType = editorType === "quill" ? "tiptap" : "quill";
    logClientEvent("compose-editor-fallback-other", { from: editorType, to: fallbackType });
    container.innerHTML = ""; // clear any partial render from the failed try
    editor = (await tryEditor(fallbackType));
    if (editor) {
        activeEditorType = fallbackType;
        setTimeout(() => showDraftStatus(`${editorType} editor unavailable — using ${fallbackType} instead.`, false), 0);
    }
}
if (!editor) {
    // Both editors failed — render a minimal contenteditable as last resort.
    // No toolbar, no rich-text shortcuts; user can still type. Status bar
    // surfaces the failure so the missing toolbar isn't a silent mystery.
    logClientEvent("compose-editor-create-failed-both", {});
    container.innerHTML = `<div class="compose-fallback-editor" contenteditable="true" style="border:1px solid #c00;padding:8px;min-height:200px;background:#fff" data-fallback="true"></div>`;
    const fallback = container.querySelector(".compose-fallback-editor");
    editor = {
        root: fallback,
        setHtml: (html) => { fallback.innerHTML = html; },
        getHtml: () => fallback.innerHTML,
        getText: () => fallback.innerText,
        focus: () => fallback.focus(),
        setCursor: () => { },
        getScrollContainer: () => fallback,
        onContentChange: (handler) => { fallback.addEventListener("input", handler); },
        onKeyDown: (handler) => { fallback.addEventListener("keydown", handler); },
        insertTextAtCursor: (text) => {
            const sel = window.getSelection();
            if (sel && sel.rangeCount > 0) {
                const range = sel.getRangeAt(0);
                range.deleteContents();
                range.insertNode(document.createTextNode(text));
            }
            else {
                fallback.append(document.createTextNode(text));
            }
        },
    };
    activeEditorType = "fallback";
    setTimeout(() => showDraftStatus(`Both editors failed to load. Plain-text fallback in use. Check the log; CDN may be unreachable.`, true), 0);
}
setActiveEditorBadge(activeEditorType);
// Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
// Persists per-session in localStorage so zoom survives window pop/close cycles.
(() => {
    const STORAGE_KEY = "mailx.compose.zoom";
    const MIN = 0.5, MAX = 3, STEP = 0.1;
    // Default 1.15 — matches the viewer's bumped default so reading and
    // composing have the same size baseline. Persisted value overrides.
    let zoom = parseFloat(localStorage.getItem(STORAGE_KEY) || "1.15") || 1.15;
    const applyZoom = () => {
        container.style.fontSize = `${zoom}em`;
        localStorage.setItem(STORAGE_KEY, String(zoom));
    };
    applyZoom();
    container.addEventListener("wheel", (e) => {
        if (!e.ctrlKey)
            return;
        e.preventDefault();
        const delta = e.deltaY < 0 ? STEP : -STEP;
        zoom = Math.min(MAX, Math.max(MIN, Math.round((zoom + delta) * 10) / 10));
        applyZoom();
    }, { passive: false });
    document.addEventListener("keydown", (e) => {
        if (!(e.ctrlKey || e.metaKey))
            return;
        if (e.key === "=" || e.key === "+") {
            zoom = Math.min(MAX, zoom + STEP);
            applyZoom();
            e.preventDefault();
        }
        else if (e.key === "-") {
            zoom = Math.max(MIN, zoom - STEP);
            applyZoom();
            e.preventDefault();
        }
        else if (e.key === "0") {
            zoom = 1;
            applyZoom();
            e.preventDefault();
        }
    });
})();
// ── Populate from init data ──
// From field is a free-text input with a <datalist> of known accounts. The
// user can pick a preset or type an arbitrary "Name <addr@domain>" — no
// separate "Other..." escape hatch, no hidden custom input toggle.
const fromInput = document.getElementById("compose-from-input");
const fromOptions = document.getElementById("compose-from-options");
// Address fields are textareas (not inputs) so they wrap to multiple lines
// when the recipient list gets long — single-line inputs scrolled off-screen
// horizontally. JS auto-grows their height on input below.
const toInput = document.getElementById("compose-to");
const ccInput = document.getElementById("compose-cc");
const bccInput = document.getElementById("compose-bcc");
const subjectInput = document.getElementById("compose-subject");
/** Resize an address textarea to fit its content. Called on input + after
 *  programmatic value sets (applyInit). Caps at 8 lines — beyond that the
 *  field scrolls to keep the overall compose window manageable. */
function autoGrowAddrInput(el) {
    if (!el)
        return;
    el.style.height = "auto";
    const max = 8 * 22; // ~8 lines × line-height
    el.style.height = Math.min(el.scrollHeight, max) + "px";
    if (el.scrollHeight > max)
        el.style.overflowY = "auto";
    else
        el.style.overflowY = "hidden";
}
for (const el of [toInput, ccInput, bccInput]) {
    el?.addEventListener("input", () => autoGrowAddrInput(el));
    // Treat Enter as a recipient separator (split on comma OR newline at
    // send time), but don't insert a literal newline — that'd break the
    // implicit "comma-separated list" assumption downstream. Replace with
    // a comma + space.
    el?.addEventListener("keydown", (e) => {
        if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
            e.preventDefault();
            const start = el.selectionStart || 0;
            const v = el.value;
            el.value = v.slice(0, start).replace(/[,\s]+$/, "") + ", " + v.slice(start).replace(/^[,\s]+/, "");
            el.selectionStart = el.selectionEnd = start + 2;
            autoGrowAddrInput(el);
        }
    });
}
let knownAccounts = [];
// ── AI ghost text autocomplete ──
if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !== "off") {
    import("./ghost-text.js").then(({ initGhostText }) => {
        initGhostText(editor, {
            getSubject: () => subjectInput.value,
            getTo: () => toInput.value,
        }, { debounceMs: appSettings.autocomplete.debounceMs || 600 });
    }).catch(() => { });
}
/** Format an account for the From field: "Name <email>". */
function formatAccountFrom(acct) {
    return `${acct.name} <${acct.email}>`;
}
const FROM_HISTORY_KEY = "mailx-from-history"; // up to 20 recent manual From entries
const FROM_HISTORY_MAX = 20;
function loadFromHistory() {
    try {
        return JSON.parse(localStorage.getItem(FROM_HISTORY_KEY) || "[]");
    }
    catch {
        return [];
    }
}
function recordFromHistory(value) {
    const v = (value || "").trim();
    if (!v)
        return;
    try {
        const list = loadFromHistory().filter(x => x !== v);
        list.unshift(v);
        localStorage.setItem(FROM_HISTORY_KEY, JSON.stringify(list.slice(0, FROM_HISTORY_MAX)));
    }
    catch { /* private mode */ }
}
/** Populate the From <datalist> with one entry per known account plus any
 *  manually-typed addresses from localStorage history. Account entries rank
 *  first; history entries get an "(used before)" label so the user can tell
 *  which ones are real accounts vs free-form aliases. */
function populateFromOptions(accounts, selectedId) {
    knownAccounts = accounts;
    fromOptions.innerHTML = "";
    const seenValues = new Set();
    for (const acct of accounts) {
        const opt = document.createElement("option");
        opt.value = formatAccountFrom(acct);
        const tag = acct.label || acct.name;
        opt.label = tag;
        fromOptions.appendChild(opt);
        seenValues.add(opt.value);
    }
    // Custom From history — addresses the user has typed before that don't
    // match any known account (aliases, +tag addresses, one-off identities).
    // Stored in localStorage because they're inherently per-device preferences;
    // moving them to an account profile would be a different feature.
    for (const value of loadFromHistory()) {
        if (seenValues.has(value))
            continue;
        const opt = document.createElement("option");
        opt.value = value;
        opt.label = "(used before)";
        fromOptions.appendChild(opt);
    }
    if (!fromInput.value) {
        const selected = (selectedId && accounts.find(a => a.id === selectedId)) ||
            accounts.find(a => a.defaultSend) ||
            accounts[0];
        if (selected)
            fromInput.value = formatAccountFrom(selected);
    }
}
/** Parse the current From input into { name, address } for header building. */
function parseFromInput() {
    const raw = fromInput.value.trim();
    const match = raw.match(/^(.+?)\s*<(.+?)>$/);
    if (match)
        return { name: match[1].trim(), address: match[2].trim() };
    return { name: "", address: raw };
}
/** Match the From input's address against the known accounts table and
 *  return that account's id. Used by send() / saveDraft() to decide which
 *  account to send through. Falls back to defaultSend, then first account. */
function getFromAccountId() {
    const { address } = parseFromInput();
    const lower = address.toLowerCase();
    // Exact match wins
    const exact = knownAccounts.find(a => a.email.toLowerCase() === lower);
    if (exact)
        return exact.id;
    // Same-domain match — handles +tag aliases and identity addresses
    const domain = lower.split("@")[1] || "";
    if (domain) {
        const sameDomain = knownAccounts.find(a => a.email.toLowerCase().endsWith("@" + domain));
        if (sameDomain)
            return sameDomain.id;
    }
    // Give up — use default send account or the first account
    const def = knownAccounts.find(a => a.defaultSend) || knownAccounts[0];
    return def?.id || "";
}
/** Get the raw From header string ("Name <addr>"). */
function getFromAddress() {
    return fromInput.value.trim();
}
/** Smart tab — skip to next empty field, ending at body */
function smartTab(current) {
    const fields = [toInput, ccInput, bccInput, subjectInput];
    const currentIdx = fields.indexOf(current);
    // Look for next empty field after current
    for (let i = currentIdx + 1; i < fields.length; i++) {
        if (!fields[i].value.trim()) {
            fields[i].focus();
            return;
        }
    }
    // All fields filled or past the end — go to editor body
    editor.focus();
}
// ── Autocomplete ──
/** Right-click on an autocomplete row → contextual actions. Two paths:
 *  - Add to preferred (small modal: name / email / source-tag / org → write to
 *    contacts.jsonc#preferred[])
 *  - Never suggest this address (write to contacts.jsonc#denylist[]; the
 *    service-side handler purges any matching discovered rows on apply) */
function showAutocompleteContextMenu(e, row) {
    showContextMenu(e.clientX, e.clientY, [
        {
            label: "Add to preferred…",
            action: () => openAddToPreferredModal(row),
        },
        {
            label: "Never suggest this address",
            action: async () => {
                try {
                    await addToDenylist(row.email);
                }
                catch (err) {
                    alert(`Failed to add to denylist: ${err?.message || err}`);
                }
            },
        },
        {
            // Punt real Google-contact editing to Google's own UI, which
            // already handles add-to-new / add-to-existing / merge — no
            // in-app picker or People API write scope needed.
            label: "Open in Google Contacts…",
            tooltip: "Add as a new contact, attach to an existing one, or merge — in Google's own UI.",
            action: () => {
                window.open(`https://contacts.google.com/search/${encodeURIComponent(row.name || row.email)}`, "_blank");
            },
        },
    ]);
}
function openAddToPreferredModal(prefill) {
    const overlay = document.createElement("div");
    overlay.className = "modal-overlay";
    overlay.innerHTML = `
        <div class="modal" role="dialog" aria-label="Add to preferred contacts">
            <h3>Add to preferred</h3>
            <p class="muted">Saved to <code>contacts.jsonc</code> on your shared drive.</p>
            <label>Name <input type="text" id="pf-name" /></label>
            <label>Email <input type="email" id="pf-email" /></label>
            <label>Source tag <input type="text" id="pf-source" placeholder="(optional — e.g. work, family)" /></label>
            <label>Organization <input type="text" id="pf-org" placeholder="(optional)" /></label>
            <div class="modal-actions">
                <button id="pf-cancel">Cancel</button>
                <button id="pf-save" class="primary">Save</button>
            </div>
        </div>
    `;
    document.body.appendChild(overlay);
    overlay.querySelector("#pf-name").value = prefill.name || "";
    overlay.querySelector("#pf-email").value = prefill.email || "";
    // Pre-fill source from existing tag if it's already a custom one (not a system source).
    const sysSources = new Set(["google", "discovered", "preferred", ""]);
    const initSource = sysSources.has(prefill.source || "") ? "" : prefill.source;
    overlay.querySelector("#pf-source").value = initSource;
    const close = () => overlay.remove();
    overlay.querySelector("#pf-cancel").addEventListener("click", close);
    overlay.addEventListener("click", (ev) => { if (ev.target === overlay)
        close(); });
    overlay.querySelector("#pf-save").addEventListener("click", async () => {
        const name = overlay.querySelector("#pf-name").value.trim();
        const email = overlay.querySelector("#pf-email").value.trim();
        const source = overlay.querySelector("#pf-source").value.trim();
        const org = overlay.querySelector("#pf-org").value.trim();
        if (!email) {
            alert("Email is required.");
            return;
        }
        try {
            await addPreferredContact({ name, email, source, organization: org });
            close();
        }
        catch (err) {
            alert(`Failed to save: ${err?.message || err}`);
        }
    });
    overlay.querySelector("#pf-name").focus();
}
function setupAutocomplete(input) {
    let dropdown = null;
    let activeIndex = -1;
    let debounce;
    function closeDropdown() {
        if (dropdown) {
            dropdown.remove();
            dropdown = null;
        }
        activeIndex = -1;
    }
    function getCaretToken() {
        const val = input.value;
        const caret = input.selectionStart ?? val.length;
        const { start, end } = tokenSpanAtCaret(val, caret);
        return val.substring(start, end).trim();
    }
    function replaceCaretToken(replacement, contact) {
        const val = input.value;
        const caret = input.selectionStart ?? val.length;
        const { start, end } = tokenSpanAtCaret(val, caret);
        let before = val.substring(0, start);
        let after = val.substring(end);
        // Strip a trailing "," / ", " from `before` — `tokenSpanAtCaret`
        // returns `start` immediately AFTER the prior separator-comma, so
        // `before` always ends with that comma. We re-add a clean ", "
        // separator below; without stripping here, ", bob" → click suggestion
        // produced "bob, "Frankston, Bob" <addr>" (the original `bob` token
        // was actually preserved by an off-by-one in some edge cases where
        // the dropdown opened on a stale `bob` while the input had advanced
        // past it). Bob 2026-05-24.
        before = before.replace(/[ \t,]+$/, "");
        // Drop "stranded partials" from before/after: prior unresolved tokens
        // (no `@`) that look like earlier attempts to type THIS contact.
        // Pattern: user types "hod" → suggestion appears → user keeps typing
        // ", p" → new suggestion for "p" → clicks Peter Hoddie. Without this
        // cleanup the input ends up as "hod, Peter Hoddie <peter@…>, " —
        // "hod" gets stranded and fails validation at send time. The
        // chosen contact's name+email becomes the haystack; any prior bare
        // token that's a substring is treated as the same intent and dropped
        // (Bob 2026-05-24).
        if (contact) {
            const haystack = `${contact.name} ${contact.email}`.toLowerCase();
            const stripStranded = (s) => splitRecipients(s)
                .map(p => p.trim())
                .filter(p => p.length > 0 && (p.includes("@") || !haystack.includes(p.toLowerCase())))
                .join(", ");
            if (before)
                before = stripStranded(before);
            const afterCore = after.replace(/^[\s,]+/, "").replace(/[\s,]+$/, "");
            if (afterCore) {
                const cleanedAfter = stripStranded(afterCore);
                after = cleanedAfter ? ", " + cleanedAfter : "";
            }
        }
        const lead = before.length ? ", " : "";
        const isLast = after.trim() === "";
        const insert = lead + replacement + (isLast ? ", " : "");
        input.value = before + insert + after;
        const pos = before.length + insert.length;
        closeDropdown();
        input.focus();
        input.setSelectionRange(pos, pos);
    }
    input.addEventListener("input", () => {
        clearTimeout(debounce);
        const token = getCaretToken();
        if (token.length < 1) {
            closeDropdown();
            return;
        }
        debounce = setTimeout(() => {
            // rAF yield before hitting the DB — S60 mitigation, same reason
            // as the draft-save path. The 200 ms timer already deferred past
            // the input burst; this extra frame lets the last keystroke paint.
            requestAnimationFrame(async () => {
                try {
                    const results = await searchContacts(token);
                    if (results.length === 0) {
                        closeDropdown();
                        return;
                    }
                    closeDropdown();
                    dropdown = document.createElement("div");
                    dropdown.className = "ac-dropdown";
                    activeIndex = 0; // first item highlighted by default
                    for (let i = 0; i < results.length; i++) {
                        const r = results[i];
                        const item = document.createElement("div");
                        item.className = `ac-item${i === 0 ? " ac-active" : ""}`;
                        // Text column (name / email / source badge).
                        const main = document.createElement("div");
                        main.className = "ac-item-main";
                        const nameEl = document.createElement("span");
                        nameEl.className = "ac-item-name";
                        nameEl.textContent = r.name || r.email;
                        const emailEl = document.createElement("span");
                        emailEl.className = "ac-item-email";
                        emailEl.textContent = r.email;
                        // Source badge(s) — shows where this row came from. When
                        // the same address is in multiple sources (e.g. google AND
                        // discovered after Google Contacts sync), the merged list
                        // is displayed comma-separated. Custom user tags from
                        // contacts.jsonc preferred entries (`source: "work"`,
                        // `source: "family"`) flow through verbatim alongside the
                        // system sources google / discovered / preferred.
                        const sourceEl = document.createElement("span");
                        sourceEl.className = "ac-item-source";
                        const sourceList = (r.sources && r.sources.length)
                            ? r.sources
                            : (r.source ? [r.source] : []);
                        sourceEl.textContent = sourceList.join(", ");
                        main.appendChild(nameEl);
                        if (r.name)
                            main.appendChild(emailEl);
                        if (sourceList.length)
                            main.appendChild(sourceEl);
                        // Visible per-row prefer / ignore buttons (shown on
                        // hover). A plain click — no dependence on right-click /
                        // the context menu, which is undiscoverable and flaky.
                        //   ★  → contacts.jsonc#preferred[]
                        //   ⊘  → contacts.jsonc#denylist[] (purges discovered)
                        const actions = document.createElement("div");
                        actions.className = "ac-item-actions";
                        const mkBtn = (glyph, title, run) => {
                            const b = document.createElement("button");
                            b.type = "button";
                            b.className = "ac-item-btn";
                            b.textContent = glyph;
                            b.title = title;
                            // Block the row's mousedown-to-select so the button
                            // acts on its own without inserting the address.
                            b.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); });
                            b.addEventListener("click", async (e) => {
                                e.preventDefault();
                                e.stopPropagation();
                                try {
                                    await run();
                                }
                                catch (err) {
                                    alert(`Failed: ${err?.message || err}`);
                                }
                                closeDropdown();
                            });
                            return b;
                        };
                        actions.appendChild(mkBtn("★", "Prefer this contact", () => addPreferredContact({ name: r.name, email: r.email, source: "", organization: "" })));
                        actions.appendChild(mkBtn("⊘", "Never suggest this address", () => addToDenylist(r.email)));
                        // First-pass alias handling: instead of an in-app
                        // add-to-existing-vs-new picker, just open Google Contacts
                        // searched for this contact so the user can merge an alias
                        // / create / edit there with Google's own UI.
                        actions.appendChild(mkBtn("↗", "Open in Google Contacts (add, edit, merge aliases)", async () => {
                            window.open(`https://contacts.google.com/search/${encodeURIComponent(r.name || r.email)}`, "_blank");
                        }));
                        item.appendChild(main);
                        item.appendChild(actions);
                        item.addEventListener("mousedown", (e) => {
                            // preventDefault on EVERY button — this is what keeps the
                            // To input focused. Without it a right-click blurs the
                            // input, blur schedules closeDropdown() 150ms later, and
                            // a deliberate right-click then fires `contextmenu` after
                            // the dropdown is already gone — landing on the editor
                            // underneath (Bob 2026-05-16). Only the LEFT button then
                            // proceeds to select-and-close; right-click falls through
                            // to the row's contextmenu handler with the row intact.
                            e.preventDefault();
                            if (e.button !== 0)
                                return;
                            const display = formatRecipient(r.name, r.email);
                            replaceCaretToken(display, { name: r.name, email: r.email });
                        });
                        // Right-click still offers the same actions (plus the
                        // full "Add to preferred…" modal) as a bonus when it works.
                        item.addEventListener("contextmenu", (e) => {
                            e.preventDefault();
                            e.stopPropagation();
                            showAutocompleteContextMenu(e, r);
                        });
                        dropdown.appendChild(item);
                    }
                    input.parentElement.appendChild(dropdown);
                    // Flip the dropdown above the input if there's no room below.
                    // Compose can be a small popup window (or the viewport can be
                    // short on phones / split screens) — when To is near the
                    // bottom, the default `top: 100%` drop spills off-screen and
                    // most matches are unreachable. Measure on insertion: if the
                    // dropdown's bottom would clear the viewport AND there's more
                    // room above the input than below, anchor it to the top of
                    // the field instead.
                    requestAnimationFrame(() => {
                        if (!dropdown)
                            return;
                        const rect = dropdown.getBoundingClientRect();
                        const inputRect = input.getBoundingClientRect();
                        const spaceBelow = window.innerHeight - inputRect.bottom;
                        const spaceAbove = inputRect.top;
                        if (rect.bottom > window.innerHeight && spaceAbove > spaceBelow) {
                            dropdown.style.top = "auto";
                            dropdown.style.bottom = "100%";
                        }
                    });
                }
                catch { /* ignore */ }
            });
        }, 200);
    });
    input.addEventListener("keydown", (e) => {
        if (!dropdown)
            return;
        const items = dropdown.querySelectorAll(".ac-item");
        const ke = e;
        if (ke.key === "ArrowDown") {
            e.preventDefault();
            activeIndex = Math.min(activeIndex + 1, items.length - 1);
            items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
        }
        else if (ke.key === "ArrowUp") {
            e.preventDefault();
            activeIndex = Math.max(activeIndex - 1, 0);
            items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
        }
        else if (ke.key === "Tab" || ke.key === "Enter") {
            if (items.length > 0) {
                e.preventDefault();
                const idx = activeIndex >= 0 ? activeIndex : 0;
                items[idx].dispatchEvent(new MouseEvent("mousedown"));
                // Stay in field — user may want to add more addresses
                return;
            }
        }
        else if (ke.key === "Escape") {
            closeDropdown();
        }
    });
    input.addEventListener("blur", () => {
        setTimeout(closeDropdown, 150);
    });
}
setupAutocomplete(toInput);
setupAutocomplete(ccInput);
setupAutocomplete(bccInput);
/** Split a recipient string on TOP-LEVEL commas only — a comma inside a
 *  quoted display name (`"Frankston, Bob" <bob@x.com>`) is part of the name,
 *  not a separator. The naive `s.split(",")` parsed such a name as two
 *  recipients (Bob 2026-05-17). */
function splitRecipients(s) {
    const out = [];
    let cur = "", inQuote = false;
    for (const c of s) {
        if (c === "\"") {
            inQuote = !inQuote;
            cur += c;
        }
        else if (c === "," && !inQuote) {
            out.push(cur);
            cur = "";
        }
        else
            cur += c;
    }
    out.push(cur);
    return out;
}
/** [start, end) span of the recipient token containing `caret`. Tokens are
 *  separated by unquoted commas (commas inside "quoted names" don't split).
 *  Autocomplete must match the token the cursor is in — not just the final
 *  one — otherwise typing a name before an existing comma matches the wrong
 *  recipient. */
function tokenSpanAtCaret(s, caret) {
    let inQuote = false;
    let start = 0;
    let end = s.length;
    for (let i = 0; i < s.length; i++) {
        if (s[i] === "\"")
            inQuote = !inQuote;
        else if (s[i] === "," && !inQuote) {
            if (i < caret)
                start = i + 1;
            else {
                end = i;
                break;
            }
        }
    }
    // Skip any leading whitespace inside the token span — without this, a
    // selection of `"Frankston, Bob" <bob@x.com>` against an input like
    // `bob` whose token span was `{0, 3}` would be fine, but if the user
    // had partially-completed a prior recipient (e.g. value `prev, bob`
    // with caret at 9 in span {6, 9}) the leading space belonged to the
    // SEPARATOR, not the token. Replacing the span as-is then leaves the
    // space in `before` AND prepends another `, ` ahead of the insert —
    // producing duplicated separators on commit. Trimming leading WS keeps
    // `before` clean and lets the replacement own its own separators.
    while (start < end && (s[start] === " " || s[start] === "\t"))
        start++;
    return { start, end };
}
/** `Name <email>` — display name quoted when it contains a comma or quote,
 *  so the recipient survives the comma-separated round-trip in the field. */
function formatRecipient(name, address) {
    if (!name)
        return address;
    const quoted = /[,"]/.test(name) ? `"${name.replace(/"/g, "'")}"` : name;
    return `${quoted} <${address}>`;
}
function formatAddrs(addrs) {
    return addrs.map(a => formatRecipient(a.name, a.address)).join(", ");
}
function parseAddrs(s) {
    if (!s.trim())
        return [];
    // Split on TOP-LEVEL commas only (see splitRecipients) so a quoted
    // comma-bearing name stays one recipient. Trailing commas / stray
    // whitespace are dropped — no phantom addresses that fail validation.
    return splitRecipients(s)
        .map(p => p.trim())
        .filter(p => p.length > 0)
        .map(part => {
        const match = part.match(/^(.+?)\s*<(.+?)>$/);
        if (match) {
            let name = match[1].trim();
            if (name.length >= 2 && name.startsWith("\"") && name.endsWith("\"")) {
                name = name.slice(1, -1); // unwrap a quoted display name
            }
            return { name, address: match[2].trim() };
        }
        return { name: "", address: part };
    });
}
/** Expand any group names in a recipient string against the user's
 *  contacts.jsonc → groups map. Recipients that match a group name (case
 *  insensitive) get replaced by their member list, recursively. Unresolved
 *  tokens are left in place so the user sees them and can fix the typo.
 *  Loaded ASYNCHRONOUSLY at compose-init time — Send must NEVER await
 *  this. Earlier `await loadGroupsMap()` inside the click handler hung
 *  Send for 61 seconds (Bob 2026-05-09 00:16:57): the GDrive read of
 *  contacts.jsonc was on a slow connection. Bob then clicked Send a
 *  second time at 00:23, producing two real outgoing messages with
 *  different Message-IDs because each Send call assigned its own. Send
 *  must be instantaneous; group expansion must not gate it.
 *
 *  localStorage is the synchronous tier — populated by the background
 *  refresh, read instantly by `expandGroups`. If localStorage has
 *  nothing yet (first ever launch on this device), expandGroups is a
 *  no-op and the user's literal recipient string flows through. The
 *  background refresh seeds localStorage on first success. */
const GROUPS_LS_KEY = "mailx-groups-cache-v1";
let _groupsCache = readGroupsFromLocalStorage();
function readGroupsFromLocalStorage() {
    try {
        const raw = localStorage.getItem(GROUPS_LS_KEY);
        if (raw)
            return JSON.parse(raw) || {};
    }
    catch { /* */ }
    return {};
}
function writeGroupsToLocalStorage(groups) {
    try {
        localStorage.setItem(GROUPS_LS_KEY, JSON.stringify(groups));
    }
    catch { /* localStorage full or private mode */ }
}
/** Background refresh of the groups map. Fires once at compose init,
 *  results land in `_groupsCache` and localStorage for next time. */
function refreshGroupsMapInBackground() {
    (async () => {
        try {
            const { readJsoncFile } = await import("../lib/api-client.js");
            const r = await readJsoncFile("contacts.jsonc");
            if (!r?.content)
                return;
            const stripped = r.content.replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1");
            const cfg = JSON.parse(stripped);
            const groups = (cfg && typeof cfg.groups === "object" && cfg.groups) ? cfg.groups : {};
            _groupsCache = groups;
            writeGroupsToLocalStorage(groups);
        }
        catch { /* leave existing cache in place */ }
    })();
}
// Kick off the background refresh once when this module loads.
refreshGroupsMapInBackground();
/** Expand the raw recipient string against the cached groups map.
 *  SYNCHRONOUS by design — Send must not wait on cloud I/O for group
 *  expansion. If no groups are cached, the input passes through. */
function expandGroups(raw) {
    if (!raw.trim())
        return raw;
    if (Object.keys(_groupsCache).length === 0)
        return raw;
    // Lazy-load the expansion utility synchronously isn't possible
    // since it's a separate package — fall back to inline expansion.
    // Same algorithm as `expandRecipients`: split on comma, look up
    // each token (case-insensitive), keep the original on miss.
    const groupsLc = {};
    for (const k of Object.keys(_groupsCache))
        groupsLc[k.toLowerCase()] = _groupsCache[k];
    const tokens = raw.split(",").map(s => s.trim()).filter(Boolean);
    const out = [];
    for (const tok of tokens) {
        const members = groupsLc[tok.toLowerCase()];
        if (members && Array.isArray(members))
            out.push(...members);
        else
            out.push(tok);
    }
    return out.join(", ");
}
function applyInit(init) {
    // Populate the From datalist with known accounts
    populateFromOptions(init.accounts, init.accountId);
    // If the reply has a specific identity address (alias / +tag), set it
    // as the From value directly — overrides the account default.
    if (init.fromAddress) {
        const account = init.accounts.find(a => a.id === init.accountId);
        const displayName = account?.name || "";
        fromInput.value = displayName ? `${displayName} <${init.fromAddress}>` : init.fromAddress;
    }
    toInput.value = formatAddrs(init.to);
    ccInput.value = formatAddrs(init.cc);
    subjectInput.value = init.subject;
    // Resize the textareas to match the freshly-loaded recipient lists —
    // without this the textareas stay 1-row even when the To: contains a
    // 200-character reply-all address list.
    autoGrowAddrInput(toInput);
    autoGrowAddrInput(ccInput);
    autoGrowAddrInput(bccInput);
    // Auto-expand Cc row if the init already has Cc content (reply-all, draft-with-cc)
    if (ccInput.value.trim()) {
        const ccRowEl = document.getElementById("compose-cc-row");
        const ccBtn = document.getElementById("btn-toggle-cc");
        if (ccRowEl)
            ccRowEl.hidden = false;
        if (ccBtn)
            ccBtn.classList.add("active");
    }
    else if (init.to && init.to.length === 1) {
        // Q49: heuristic auto-expand — when replying/composing to a single
        // recipient, check sent-history. If the user has previously Cc'd or
        // Bcc'd anyone on a message to this recipient, expand the matching
        // row (empty, just visible) so they're prompted to fill it.
        // Fire-and-forget; if the service call fails or the user starts
        // typing manually before it resolves, the answer doesn't matter.
        const firstEmail = init.to[0]?.address || "";
        if (firstEmail) {
            import("../lib/api-client.js").then(({ hasCcHistoryTo, hasBccHistoryTo }) => {
                hasCcHistoryTo(firstEmail)
                    .then(res => {
                    if (!res?.hasCc)
                        return;
                    const ccRowEl = document.getElementById("compose-cc-row");
                    const ccBtn = document.getElementById("btn-toggle-cc");
                    if (ccRowEl?.hidden && !ccInput.value) {
                        ccRowEl.hidden = false;
                        ccBtn?.classList.add("active");
                    }
                })
                    .catch(() => { });
                hasBccHistoryTo(firstEmail)
                    .then(res => {
                    if (!res?.hasBcc)
                        return;
                    const bccRowEl = document.getElementById("compose-bcc-row");
                    const bccBtn = document.getElementById("btn-toggle-bcc");
                    if (bccRowEl?.hidden && !bccInput.value) {
                        bccRowEl.hidden = false;
                        bccBtn?.classList.add("active");
                    }
                })
                    .catch(() => { });
            });
        }
    }
    // C42: append the account's signature (if configured) BEFORE rendering
    // the body. Two sources, in priority order:
    //   1. New `sig: { text, html? }` object — applied to NEW messages only.
    //      `text` is HTML-escaped (newlines → <br>) unless `html: true` is set
    //      (reserved for future use; currently `html` is ignored and text is
    //      always escaped).
    //   2. Legacy `signature: string` — HTML, applied to new + reply + forward.
    //
    // Drafts are skipped — the signature is already baked into the saved body.
    // Editing an existing draft also skipped.
    let bodyToRender = init.bodyHtml || "";
    // Preserve hard line breaks inside <pre> blocks. Thunderbird-authored
    // drafts and reply quotes wrap quoted text in `<pre wrap="" class="moz-quote-pre">`
    // with literal `\n` line breaks. Browsers preserve those breaks because
    // `<pre>` is whitespace-significant, but the rich-text editor (Quill /
    // TinyMCE) normalises `<pre>` to a flow block on import and silently
    // collapses internal newlines into spaces — so a draft that wrapped
    // correctly on disk shows up as one long unbroken paragraph in compose.
    // Bob 2026-05-13: "lost the line wrapping on a draft I'm editing." Fix:
    // before handing the body to the editor, convert `\n` inside any `<pre>`
    // block to `<br>` so the visual breaks survive whatever the editor's
    // import normaliser does. The `<pre>` element stays; we only mutate its
    // contents.
    bodyToRender = bodyToRender.replace(/<pre\b([^>]*)>([\s\S]*?)<\/pre>/gi, (_match, attrs, inner) => `<pre${attrs}>${inner.replace(/\r\n|\r|\n/g, "<br>")}</pre>`);
    const acct = init.accounts.find(a => a.id === init.accountId);
    const isReplyForward = init.mode === "reply" || init.mode === "replyAll"
        || init.mode === "forward";
    // Signature — applies to new mail AND replies/forwards (drafts already
    // have it baked into the saved body). New format `acct.sig` {text,html}
    // takes precedence; legacy `acct.signature` (raw HTML) is the fallback.
    // Earlier the new-format branch was gated on "new mail only", so anyone
    // using the new sig format got NO signature on a reply (Bob 2026-05-18).
    if (init.mode !== "draft" && !init.draftUid) {
        let sigHtml = "";
        if (acct?.sig?.text) {
            sigHtml = acct.sig.html
                ? acct.sig.text // trusted raw HTML
                : escapeHtml(acct.sig.text).replace(/\n/g, "<br>");
        }
        else if (acct?.signature) {
            sigHtml = acct.signature;
        }
        if (sigHtml) {
            const sigBlock = `<br><br>-- <br>${sigHtml}`;
            bodyToRender = isReplyForward
                ? `<br>${sigBlock}<br>${bodyToRender}` // sig above the quoted block
                : `${bodyToRender}${sigBlock}`; // sig at the end for new mail
        }
    }
    if (bodyToRender) {
        editor.setHtml(bodyToRender);
        editor.setCursor(0);
    }
    // If resuming a draft, track its UID for deletion after send
    if (init.draftUid) {
        draftUid = init.draftUid;
    }
    // Capture reply/forward linkage. init.inReplyTo is the parent's Message-ID,
    // init.references is the full ancestry chain (parent's existing References
    // header plus the parent's own Message-ID). Both set by app.ts:openCompose
    // for mode === "reply" | "replyAll" | "forward".
    replyInReplyTo = init.inReplyTo || "";
    replyReferences = init.references || [];
    setComposeTitle(init.subject || "");
    // Focus first empty field: To → Subject → body
    if (!toInput.value)
        toInput.focus();
    else if (!subjectInput.value)
        subjectInput.focus();
    else
        editor.focus();
    // Record the post-init content so autosave / hasContent only react to
    // genuine user edits. Without this baseline, a reply opens with quoted
    // body + signature already populated and autosave fires within seconds
    // even if the user typed nothing — producing the "draft droppings"
    // that pile up in the Drafts folder.
    recordContentBaseline();
}
// Q68: dirty marker (•) in the window title until the next successful save.
let composeDirty = false;
function setComposeTitle(subject) {
    const base = subject ? `${subject} - Compose` : "Compose - mailx";
    document.title = composeDirty ? `• ${base}` : base;
}
function markComposeDirty() {
    if (composeDirty)
        return;
    composeDirty = true;
    setComposeTitle(subjectInput?.value || "");
}
function markComposeClean() {
    if (!composeDirty)
        return;
    composeDirty = false;
    setComposeTitle(subjectInput?.value || "");
}
// ── Compose state (declared before init so the async IIFE can reference them) ──
const DRAFT_INPUT_DEBOUNCE_MS = 800; // save ~0.8s after the last keystroke
const DRAFT_INTERVAL_MS = 2000; // safety-net interval save
let draftUid = null;
let draftId = null; // stable ID for dedup when APPENDUID unavailable
// Reply / Reply-All / Forward — captured at init time, forwarded to the daemon
// so the outgoing MIME gets `In-Reply-To` + `References` headers. Without these
// the reply lands as a top-level message: no threading anywhere, no ↩ indicator
// on the original (since the linkage signal is the header itself). Mirror of
// draftUid/draftId at module scope so the btn-send handler can read them.
let replyInReplyTo = "";
let replyReferences = [];
let draftTimer = null;
let draftDebounceTimer = null;
let lastDraftContent = "";
// Snapshot of compose content right after init populates it (signature, quoted
// body, pre-filled recipients). composeHasUserChanges() compares against this
// so autosave and the Save/Discard prompt only react to genuine user edits.
let baselineSnapshot = "";
function getContentSnapshot() {
    return JSON.stringify({
        body: editor?.getHtml() || "",
        to: toInput?.value || "",
        cc: ccInput?.value || "",
        bcc: bccInput?.value || "",
        subject: subjectInput?.value || "",
    });
}
function recordContentBaseline() {
    baselineSnapshot = getContentSnapshot();
    // Seed lastDraftContent so the first autosave-tick after init doesn't
    // fire just because the snapshot differs from the empty string.
    lastDraftContent = baselineSnapshot;
}
function composeHasUserChanges() {
    return getContentSnapshot() !== baselineSnapshot;
}
let draftSaving = false; // prevent concurrent saves
let draftSaveFailed = false; // surfaced in the compose status tag
const attachments = [];
function showDraftStatus(text, isError) {
    const status = document.getElementById("compose-status");
    if (!status)
        return;
    status.textContent = text;
    status.classList.toggle("compose-status-error", isError);
}
async function saveDraft() {
    if (draftSaving)
        return; // previous save still in flight
    // Skip autosave when nothing has changed from the post-init baseline
    // (signature, quoted body, pre-filled To). Editing an existing draft
    // bypasses the check — we still need to round-trip user edits to the
    // server-side draft. Without this, replies/forwards saved a draft the
    // moment they opened, even with no user typing.
    if (!draftUid && !draftId && !composeHasUserChanges())
        return;
    // Don't checkpoint a meaningless draft. A fresh compose with no subject,
    // no body, and no REAL recipient (a bare word like "sherrik" typed into
    // To: when the user thought focus was in the search box) is junk — it
    // littered Bob's mailbox with "To: sherrik <>" drafts 2026-05-20. The
    // `\S+@\S+` test is a cheap "is there an actual address" proxy. Editing
    // an existing draft (draftUid/draftId set) always saves — we must
    // round-trip the user's edits even if they cleared all the fields.
    if (!draftUid && !draftId) {
        const hasSubject = subjectInput.value.trim().length > 0;
        const hasBody = editor.getText().trim().length > 0;
        const hasRealRecipient = /\S+@\S+/.test(`${toInput.value} ${ccInput.value} ${bccInput.value}`);
        if (!hasSubject && !hasBody && !hasRealRecipient)
            return;
    }
    const content = getContentSnapshot();
    if (content === lastDraftContent)
        return; // no changes since last save
    // Expose to window for blur-handler.
    window.__mailxSaveDraft = saveDraft;
    lastDraftContent = content;
    draftSaving = true;
    try {
        const data = await apiSaveDraft({
            accountId: getFromAccountId(),
            subject: subjectInput.value,
            bodyHtml: editor.getHtml(),
            bodyText: editor.getText(),
            to: toInput.value,
            cc: ccInput.value,
            previousDraftUid: draftUid,
            draftId: draftId,
        });
        if (data?.draftUid)
            draftUid = data.draftUid;
        if (data?.draftId)
            draftId = data.draftId;
        if (draftSaveFailed) {
            draftSaveFailed = false;
            showDraftStatus("Draft saved", false);
        }
        else
            showDraftStatus(`Draft saved ${new Date().toLocaleTimeString()}`, false);
        markComposeClean();
    }
    catch (e) {
        // Surface the error — silent failures are how drafts get lost on IMAP hiccups.
        // The local editing/ checkpoint already exists server-side regardless.
        console.error("[draft] save failed:", e);
        draftSaveFailed = true;
        showDraftStatus(`Draft save failed: ${e?.message || e}`, true);
        // Clear lastDraftContent so the next tick retries the same content
        lastDraftContent = "";
    }
    finally {
        draftSaving = false;
    }
}
/** Schedule a debounced save on user input — fires ~1.5s after the last
 *  keystroke, then yields one animation frame before actually writing so
 *  the browser can paint any keystroke in the mean time. S60 mitigation:
 *  wa-sqlite writes are synchronous on Android; this keeps the typing
 *  experience responsive by never running the write in the same task as
 *  an input event. */
function scheduleDraftSave() {
    markComposeDirty();
    if (draftDebounceTimer)
        clearTimeout(draftDebounceTimer);
    draftDebounceTimer = setTimeout(() => {
        draftDebounceTimer = null;
        // rAF yield — lets any pending keystroke render before we block on
        // the DB write. A no-op when the tab is hidden (rAF is throttled),
        // which is fine because the user isn't typing then either.
        requestAnimationFrame(() => { saveDraft(); });
    }, DRAFT_INPUT_DEBOUNCE_MS);
}
// ── Initialize: local-first population.
//
// Reply / Reply-All / Forward callers pre-populate `init.accounts` with the
// full account list (app.ts:openCompose). In that common case we do NOT need
// to call getAccounts() — everything required to fill the compose form is
// already in sessionStorage and reads synchronously. That turns "click Reply"
// into an instant-open instead of "wait for getAccounts IPC to respond,
// which can take >120s when the service is busy syncing / hung on IMAP".
//
// getAccounts is still called (non-blocking) to refresh the dropdown with
// the freshest data — and it IS awaited only in the fallback path where
// init doesn't have an account list (message-viewer's Edit Draft passes
// init.accounts=[]).
// Parent (app.ts:openCompose) opens this iframe in parallel with its
// `getAccounts()` IPC. If sessionStorage isn't populated yet by the time
// we get here, wait briefly for the parent's `compose-init-ready`
// postMessage. Listener registered as early as possible so a fast parent
// post that beats this IIFE still gets captured.
let _postedInit = null;
let _parentInitReady = !!sessionStorage.getItem("composeInit");
const _parentInitListeners = [];
let _msgEventCount = 0;
window.addEventListener("message", (e) => {
    _msgEventCount++;
    // Accept either the legacy ready-signal (paired with sessionStorage) or
    // the newer payload-carrying message — postMessage is the fallback for
    // when sessionStorage doesn't bridge to the iframe (WebView2 quirk on
    // the custom-protocol host).
    if (e.data?.type === "compose-init-ready") {
        _parentInitReady = true;
        for (const fn of _parentInitListeners.splice(0))
            fn();
    }
    else if (e.data?.type === "compose-init" && e.data.init) {
        if (!_postedInit) {
            try {
                logClientEvent("compose-init-received", { src: "postMessage" });
            }
            catch { /* */ }
        }
        _postedInit = e.data.init;
        _parentInitReady = true;
        for (const fn of _parentInitListeners.splice(0))
            fn();
    }
});
// Last-resort handoff: pull init from the parent window's keyed stash.
// sessionStorage AND postMessage have both failed in production
// (Bob 2026-05-24: replyAll opened with empty fields; no init parsed).
// Same-origin iframe → window.parent access is allowed by SOP and is
// synchronous (no race). Keyed by a token the parent set on iframe.dataset.
function pullInitFromParent() {
    try {
        const myFrame = window.frameElement;
        const key = myFrame?.dataset?.composeKey;
        if (!key)
            return null;
        const stash = window.parent?.__mailxComposeInits;
        const init = stash?.[key];
        if (init) {
            try {
                logClientEvent("compose-init-received", { src: "parent-stash", key });
            }
            catch { /* */ }
            return init;
        }
    }
    catch (e) {
        try {
            logClientEvent("compose-init-pull-failed", { err: e?.message || String(e) });
        }
        catch { /* */ }
    }
    return null;
}
function waitForParentInit(maxMs) {
    if (_parentInitReady)
        return Promise.resolve();
    return new Promise(resolve => {
        const timer = setTimeout(() => { _parentInitReady = true; resolve(); }, maxMs);
        _parentInitListeners.push(() => { clearTimeout(timer); resolve(); });
    });
}
(async () => {
    _ctick("init IIFE start");
    // Try parent-stash FIRST — it's synchronous and most reliable. Only
    // wait/poll if it's empty.
    let parentInit = pullInitFromParent();
    if (!parentInit && !sessionStorage.getItem("composeInit") && !_postedInit) {
        _ctick("waiting for parent init");
        await waitForParentInit(1500);
        _ctick(`parent init received (msgEvents=${_msgEventCount})`);
        // Re-poll the parent stash — it may have been populated during the wait.
        if (!_postedInit)
            parentInit = pullInitFromParent();
    }
    // Priority: parent-stash > postMessage > sessionStorage.
    const stored = sessionStorage.getItem("composeInit");
    const initRaw = parentInit
        || _postedInit
        || (stored ? JSON.parse(stored) : null);
    if (initRaw) {
        sessionStorage.removeItem("composeInit");
        const init = initRaw;
        const src = parentInit ? "parent-stash" : (_postedInit ? "postMessage" : "sessionStorage");
        _ctick(`init parsed (mode=${init.mode}, bodyHtml=${init.bodyHtml?.length || 0} bytes, src=${src})`);
        if (init.accounts && init.accounts.length > 0) {
            // Happy path — init is complete. Apply immediately. Kick
            // getAccounts in the background to refresh the dropdown if the
            // user keeps compose open long enough for the result.
            applyInit(init);
            _ctick("applyInit done — compose visible");
            getAccounts().then((fresh) => {
                if (Array.isArray(fresh) && fresh.length > 0) {
                    init.accounts = fresh;
                    // Re-populate the From dropdown only — don't clobber
                    // anything the user may have already typed.
                    try {
                        populateFromOptions(fresh);
                    }
                    catch { /* */ }
                }
            }).catch(() => { });
        }
        else {
            // Edit Draft / other callers that didn't pre-fill accounts.
            // Have to wait on getAccounts here — the From dropdown needs it.
            let fresh = [];
            try {
                fresh = await getAccounts();
            }
            catch (e) {
                console.error("Failed to load accounts:", e);
            }
            init.accounts = fresh;
            applyInit(init);
        }
    }
    else {
        let accounts = [];
        try {
            accounts = await getAccounts();
        }
        catch (e) {
            console.error("Failed to load accounts:", e);
        }
        populateFromOptions(accounts);
        toInput.focus();
    }
    // Wire debounced saves to input events — checkpoint ~1.5s after the last
    // keystroke instead of waiting up to 5s for the interval tick.
    toInput.addEventListener("input", scheduleDraftSave);
    ccInput.addEventListener("input", scheduleDraftSave);
    bccInput.addEventListener("input", scheduleDraftSave);
    subjectInput.addEventListener("input", scheduleDraftSave);
    editor.onContentChange(scheduleDraftSave);
    // Safety-net interval: even with no user input, catch any edge cases.
    draftTimer = setInterval(saveDraft, DRAFT_INTERVAL_MS);
    // 2026-05-13 safety net: poll editor state directly every 4s and force
    // a save when the body has changed since the last seen snapshot. Bypasses
    // the editor's change-event wiring (TinyMCE's `update` event silently
    // missed Bob's 2h 15m of typing today — log shows zero saveDraft IPC).
    // Compares the canonicalized editor HTML, not the JSON snapshot, so
    // TinyMCE renormalisation doesn't trip the check on every poll. Until
    // the root TinyMCE-event bug is fixed, this guarantees disk recovery.
    //
    // Backoff: when saveDraft fails (e.g., the IPC times out because the
    // daemon is hung on a slow IMAP fetch), we don't want to refire every
    // 4 s and re-paint the error banner. The polling loop reads
    // `draftSaveFailed` and skips a tick when the previous attempt failed,
    // doubling the silent window each time up to ~30 s. Resets on the
    // first success.
    let lastPolledHtml = editor.getHtml();
    let _pollBackoffSkips = 0;
    let _pollSkipsRemaining = 0;
    setInterval(() => {
        try {
            if (_pollSkipsRemaining > 0) {
                _pollSkipsRemaining--;
                return;
            }
            const current = editor.getHtml();
            if (current === lastPolledHtml)
                return;
            lastPolledHtml = current;
            markComposeDirty();
            lastDraftContent = "";
            const wasFailing = draftSaveFailed;
            saveDraft().then(() => {
                // Successful save resets the backoff window.
                if (!draftSaveFailed)
                    _pollBackoffSkips = 0;
            }).catch(() => { });
            if (wasFailing || draftSaveFailed) {
                // Last attempt is still in-flight or failed — wait longer
                // before the next force-flush. 4 s * (2,4,6,8) → ~32 s ceiling.
                _pollBackoffSkips = Math.min(_pollBackoffSkips + 1, 7);
                _pollSkipsRemaining = _pollBackoffSkips;
            }
        }
        catch { /* poll loop must never throw */ }
    }, 4000);
    // Flush the draft on window close so the last-typed content lands in
    // editing/ even if the interval tick hasn't fired yet. navigator.sendBeacon
    // is synchronous enough to survive unload; callNode IPC would be dropped.
    window.addEventListener("beforeunload", () => {
        if (draftDebounceTimer) {
            clearTimeout(draftDebounceTimer);
            draftDebounceTimer = null;
        }
        // fire-and-forget — can't await during unload
        saveDraft();
    });
})();
// ── Send ──
// Q55: Ctrl+Enter (or Cmd+Enter on macOS) anywhere in compose triggers send.
document.addEventListener("keydown", (e) => {
    if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
        e.preventDefault();
        document.getElementById("btn-send")?.click();
    }
});
// Q59: autosave when the window loses focus (in addition to debounce + interval).
window.addEventListener("blur", () => {
    // Use the same saveDraft path as the 5s interval.
    try {
        window.__mailxSaveDraft?.();
    }
    catch { /* */ }
});
document.getElementById("btn-send")?.addEventListener("click", async () => {
    // Loud tracing through the whole send pipeline. Every step ships a
    // `[client] compose-send-*` event to the Node log so a "vanished message"
    // report can be traced end-to-end without devtools. If the log stops
    // at any step, that's where the pipeline broke.
    logClientEvent("compose-send-click");
    // Group expansion — replace any group names (family, neighbors, etc.)
    // with their address lists from contacts.jsonc → groups before parsing
    // into the validated {name, address}[] shape the service expects.
    // Synchronous group expansion — reads in-memory + localStorage cache
    // ONLY. NEVER awaits cloud I/O. This used to be `await expandGroups`
    // which fired a GDrive read of contacts.jsonc and hung Send for 61 s
    // on a slow network (Bob 2026-05-09 00:16:57); the user clicked
    // Send again, producing a real duplicate message.
    const toExpanded = expandGroups(toInput.value);
    const ccExpanded = expandGroups(ccInput.value);
    const bccExpanded = expandGroups(bccInput.value);
    const body = {
        from: getFromAccountId(),
        fromAddress: getFromAddress(),
        to: parseAddrs(toExpanded),
        cc: parseAddrs(ccExpanded),
        bcc: parseAddrs(bccExpanded),
        subject: subjectInput.value,
        bodyHtml: editor.getHtml(),
        bodyText: editor.getText(),
        attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
        // Threading headers — daemon writes In-Reply-To and References into
        // the outgoing MIME from these. Without them every reply lands as a
        // top-level message and the ↩ indicator never appears on the original
        // (linkage is by header, not by anything client-only).
        inReplyTo: replyInReplyTo,
        references: replyReferences,
        // Hand draft identifiers to the daemon so it owns post-send cleanup.
        // Previously the client fired deleteDraft() as a separate IPC after
        // send, fire-and-forget with a silent catch — when the IMAP path
        // hiccuped the draft survived ("draft droppings"). Daemon-side
        // cleanup queues a sync_action on failure for reliable retry.
        draftUid: draftUid ?? undefined,
        draftId: draftId ?? undefined,
    };
    logClientEvent("compose-send-body-built", { from: body.from, toCount: body.to.length, subjectLen: (body.subject || "").length, bodyHtmlLen: (body.bodyHtml || "").length, atts: body.attachments.length });
    // Local validity (one missing-To check) — must run before close so the
    // user gets an inline error instead of silent loss. Anything else (real
    // address validation, MIME assembly, disk write) happens server-side.
    if (!body.to.length) {
        logClientEvent("compose-send-rejected-no-to");
        alert("Please add at least one To recipient.");
        return;
    }
    // Local-first send: validate fast in the client, then fire-and-forget
    // the IPC and close compose IMMEDIATELY. The body is already snapshotted
    // into `body` above; nothing the user types after this point can affect
    // what gets sent.
    //
    // Earlier "wait for IPC ack before closing" version produced a real,
    // user-reported bug: clicking Send → typing for a few hundred ms while
    // the IPC was in flight → IPC returns → compose closes mid-keystroke
    // and the user's last edits go nowhere. The fix is structural: send
    // is committed by the client validation + the snapshot, NOT by the
    // server's reply. The server-side write is synchronous-on-disk inside
    // service.send() (queueOutgoingLocal), and any failure surfaces as a
    // top-level banner via the parent's `mailx-send-error` postMessage.
    //
    // Client-side regex validation up front so a typo'd address bounces
    // back inline (compose stays open) instead of disappearing into a
    // toast after compose has closed. Same pattern Outlook/Thunderbird use.
    const emailRe = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
    const badAddress = (list) => {
        if (!list)
            return null;
        for (const a of list) {
            const addr = (a?.address || "").trim();
            if (!addr)
                continue; // empty fragments are dropped
            if (!emailRe.test(addr))
                return addr;
        }
        return null;
    };
    const bad = badAddress(body.to) || badAddress(body.cc) || badAddress(body.bcc);
    if (bad) {
        logClientEvent("compose-send-rejected-bad-addr", { addr: bad });
        const statusEl = document.getElementById("compose-status");
        if (statusEl) {
            statusEl.textContent = `Invalid address: "${bad}" — Send blocked`;
            // Toggle the error class so the high-contrast white-on-red
            // styling kicks in; otherwise the message used the muted-grey
            // default color and was missable (Bob 2026-05-24).
            statusEl.classList.add("compose-status-error");
        }
        else
            alert(`Invalid address: "${bad}"`);
        return;
    }
    console.log(`[compose] Send clicked: from=${body.from} to=${JSON.stringify(body.to)} subject="${body.subject}" attachments=${body.attachments.length}`);
    // Stop autosave NOW — the body we're sending is the body we're sending,
    // and we don't want a stray autosave to write a stale "still typing"
    // copy back to the Drafts folder while SMTP is in flight.
    if (draftTimer) {
        clearInterval(draftTimer);
        draftTimer = null;
    }
    // Record From-address history before close. Only manual values worth
    // keeping — skip anything that exactly matches a known account.
    try {
        const raw = fromInput.value.trim();
        const known = knownAccounts.some(a => formatAccountFrom(a) === raw);
        if (raw && !known && /@.+\./.test(raw))
            recordFromHistory(raw);
    }
    catch { /* */ }
    // Draft cleanup is now part of the send IPC payload (body.draftUid /
    // body.draftId) — daemon handles deletion with retry semantics. The
    // earlier client-side deleteDraft() call here was fire-and-forget and
    // could silently fail, leaving stale drafts in the IMAP folder.
    const sendStart = Date.now();
    logClientEvent("compose-send-pre-ipc");
    // Fire the parent-relay IPC and don't await it. Parent will postMessage
    // a `mailx-send-error` to the parent window on failure; the app handles
    // that with a top-level banner. On success the outbox-status pill picks
    // up the queued message via the daemon's outboxStatus event.
    try {
        const reqId = `send-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
        // Result listener: log only. Compose is already closed by the time
        // any of these fire.
        const onMsg = (ev) => {
            if (!ev.data || ev.data.type !== "mailx-compose-send-result" || ev.data.id !== reqId)
                return;
            window.removeEventListener("message", onMsg);
            if (ev.data.ok) {
                logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
            }
            else {
                const msg = ev.data.error || "unknown";
                logClientEvent("compose-send-ipc-rejected", { error: msg, ms: Date.now() - sendStart });
                // Bubble the error up so the parent can show a banner. The
                // dropped-into-outbox path (queueOutgoingLocal) catches most
                // failures locally, so this branch fires only when the
                // *queue write itself* failed — rare, but the user must see
                // it because the message would otherwise be lost silently.
                try {
                    parent.postMessage({ type: "mailx-send-error", message: msg, accountId: body.from }, "*");
                }
                catch { /* */ }
            }
        };
        window.addEventListener("message", onMsg);
        // Safety: if parent never replies (msger pipe broken), prune the
        // listener after 120s so we don't leak it. No retry here — the
        // daemon's outbox worker is the retry path.
        setTimeout(() => window.removeEventListener("message", onMsg), 120000);
        parent.postMessage({ type: "mailx-compose-send", id: reqId, body }, "*");
        logClientEvent("compose-send-ipc-invoked", { via: "parent-relay", reqId });
    }
    catch (e) {
        // postMessage itself threw — bridge totally dead. This is the only
        // path where we keep compose open, since we couldn't even hand the
        // body off.
        const msg = e?.message || String(e);
        logClientEvent("compose-send-ipc-throw", { error: msg });
        const sendBtn = document.getElementById("btn-send");
        if (sendBtn) {
            sendBtn.disabled = false;
            sendBtn.textContent = "Send";
        }
        const statusEl = document.getElementById("compose-status");
        if (statusEl)
            statusEl.textContent = `Bridge error: ${msg}`;
        return;
    }
    closeCompose();
});
// ── Close handling ──
/** True if the compose has user-driven changes worth prompting about. Compares
 *  against the post-init baseline rather than "any non-empty content" — a
 *  reply has quoted body + signature pre-populated, so the old "any content"
 *  check produced unwanted Save/Discard prompts on closes with no user input. */
function composeHasContent() {
    return composeHasUserChanges();
}
/** Ask Save/Discard/Cancel. Returns "save" | "discard" | "cancel".
 *  Uses an in-page modal so all three choices are presented at once — the
 *  native confirm() flow forced the user through two sequential dialogs and
 *  hid Discard behind a Cancel click, which was confusing. */
function promptSaveOrDiscard() {
    return new Promise(resolve => {
        const overlay = document.createElement("div");
        overlay.className = "compose-modal-overlay";
        const box = document.createElement("div");
        box.className = "compose-modal";
        const msg = document.createElement("div");
        msg.className = "compose-modal-msg";
        msg.textContent = "Save this message as a draft?";
        const btnRow = document.createElement("div");
        btnRow.className = "compose-modal-buttons";
        const mkBtn = (label, choice, primary) => {
            const b = document.createElement("button");
            b.type = "button";
            b.textContent = label;
            b.className = primary ? "compose-modal-btn primary" : "compose-modal-btn";
            b.addEventListener("click", () => { cleanup(); resolve(choice); });
            return b;
        };
        const cleanup = () => {
            document.removeEventListener("keydown", onKey);
            overlay.remove();
        };
        const onKey = (e) => {
            if (e.key === "Escape") {
                e.preventDefault();
                cleanup();
                resolve("cancel");
            }
            else if (e.key === "Enter") {
                e.preventDefault();
                cleanup();
                resolve("save");
            }
        };
        document.addEventListener("keydown", onKey);
        btnRow.appendChild(mkBtn("Save draft", "save", true));
        btnRow.appendChild(mkBtn("Discard", "discard", false));
        btnRow.appendChild(mkBtn("Cancel", "cancel", false));
        box.appendChild(msg);
        box.appendChild(btnRow);
        overlay.appendChild(box);
        document.body.appendChild(overlay);
        btnRow.firstChild.focus();
    });
}
/** Handle any "close the compose" action (Discard button, Escape, X, window close). */
async function handleCloseRequest() {
    if (!composeHasContent()) {
        closeCompose();
        return true;
    }
    const choice = await promptSaveOrDiscard();
    if (choice === "cancel")
        return false;
    // Stop auto-save so it can't race with our explicit save/discard.
    if (draftDebounceTimer) {
        clearTimeout(draftDebounceTimer);
        draftDebounceTimer = null;
    }
    if (draftTimer) {
        clearInterval(draftTimer);
        draftTimer = null;
    }
    if (choice === "save") {
        try {
            await saveDraft();
        }
        catch { /* already logged */ }
    }
    else {
        // Discard: delete the tracked draft so the orphan doesn't stick
        // around — but fire-and-forget. The IMAP draft delete is a server
        // round-trip; awaiting it held the window open for seconds (Bob:
        // "I press X but it doesn't go away ... eventually it did"). The
        // close is the user's action and must complete instantly.
        if (draftUid || draftId) {
            deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => { });
        }
    }
    closeCompose();
    return true;
}
document.getElementById("btn-discard")?.addEventListener("click", async () => {
    // Explicit Discard — the user already declared intent. Don't re-prompt
    // them with Save/Discard/Cancel (handleCloseRequest is for the X / Esc
    // path, which is ambiguous and needs the prompt). Just delete the
    // tracked draft (if any) and close.
    // Skip the confirm dialog when there's nothing to lose. Same rule the
    // X/Esc path uses (`composeHasContent` covers body + to/cc/bcc/subject)
    // — empty compose just closes.
    if (composeHasContent() && !confirm("Discard this draft? Any unsaved content will be lost."))
        return;
    if (draftDebounceTimer) {
        clearTimeout(draftDebounceTimer);
        draftDebounceTimer = null;
    }
    if (draftTimer) {
        clearInterval(draftTimer);
        draftTimer = null;
    }
    // Fire-and-forget — the IMAP draft delete must not hold the window open.
    if (draftUid || draftId) {
        deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => { });
    }
    closeCompose();
});
// ── Cc / Bcc toggle ──
const ccRow = document.getElementById("compose-cc-row");
const bccRow = document.getElementById("compose-bcc-row");
const toggleCcBtn = document.getElementById("btn-toggle-cc");
const toggleBccBtn = document.getElementById("btn-toggle-bcc");
function setCcVisible(visible) {
    ccRow.hidden = !visible;
    toggleCcBtn.classList.toggle("active", visible);
    // Visibility ≠ existence. Hiding the row keeps the value intact so
    // toggling it back shows what was there. Send still picks up the
    // value from a hidden row — the field exists in the DOM, just hidden.
    if (visible)
        ccInput.focus();
}
function setBccVisible(visible) {
    bccRow.hidden = !visible;
    toggleBccBtn.classList.toggle("active", visible);
    if (visible)
        bccInput.focus();
}
toggleCcBtn?.addEventListener("click", () => setCcVisible(ccRow.hidden));
toggleBccBtn?.addEventListener("click", () => setBccVisible(bccRow.hidden));
// Q49 deferred: should be derived from the address book / sent-history DB,
// not a parallel localStorage store. Pending: extend contacts schema or
// query messages table on To-input change (debounced); auto-expand Cc/Bcc
// when this recipient's history shows ≥N past uses.
// ── Attachments ──
const fileInput = document.getElementById("compose-file");
const attEl = document.getElementById("compose-attachments");
function renderAttachmentChips() {
    attEl.innerHTML = "";
    if (attachments.length === 0) {
        attEl.hidden = true;
        return;
    }
    attEl.hidden = false;
    for (let i = 0; i < attachments.length; i++) {
        const a = attachments[i];
        const chip = document.createElement("span");
        chip.className = "compose-att-chip";
        chip.innerHTML = `\uD83D\uDCCE ${escapeHtml(a.filename)} (${formatSize(a.size)}) `;
        const rm = document.createElement("button");
        rm.type = "button";
        rm.title = "Remove attachment";
        rm.textContent = "\u2715";
        rm.addEventListener("click", () => {
            attachments.splice(i, 1);
            renderAttachmentChips();
        });
        chip.appendChild(rm);
        attEl.appendChild(chip);
    }
}
function escapeHtml(s) {
    return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function formatSize(n) {
    if (n < 1024)
        return `${n} B`;
    if (n < 1024 * 1024)
        return `${(n / 1024).toFixed(1)} KB`;
    return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** Set when the user clicks Attach — the native file picker eats the Esc
 *  press, but on Windows WebView2 the keydown can still spill to the page
 *  and trip the document-level Esc-closes-compose handler. While this flag
 *  is set, that handler short-circuits. Cleared shortly after click. */
let attachJustClicked = 0;
document.getElementById("btn-attach")?.addEventListener("click", () => {
    attachJustClicked = Date.now();
    fileInput?.click();
});
// ── Edit in Word (external editor handoff) ──
//
// Click writes the current body to a temp file, opens it in Word (or the
// platform fallback), and watches the file. When Word saves, the service
// emits `wordEditUpdated` and we replace the editor's HTML with the new
// content. The editId is per-compose-window — closeWordEdit cleans up the
// temp file when the window closes or the message is sent.
let wordEditId = null;
// Edit-in-Word is desktop-only — hide the button on Android where the file
// system / external-editor path can't work. Detection: the MAUI WebView
// exposes `mailxapi.platform === "android"` once the bridge is up.
{
    const isAndroid = window.mailxapi?.platform === "android"
        || window.parent?.mailxapi?.platform === "android";
    if (isAndroid) {
        const btn = document.getElementById("btn-edit-in-word");
        if (btn)
            btn.hidden = true;
    }
}
document.getElementById("btn-edit-in-word")?.addEventListener("click", async () => {
    if (!wordEditId)
        wordEditId = `compose-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
    showDraftStatus("Opening in Word…", false);
    try {
        const result = await openInWord(wordEditId, editor.getHtml());
        if (!result.ok || result.opener === "none") {
            showDraftStatus("Couldn't launch an editor. Install Word, LibreOffice, or set a default for .html.", true);
            return;
        }
        const label = result.opener === "word" ? "Word" :
            result.opener === "libreoffice" ? "LibreOffice" :
                "your default editor";
        showDraftStatus(`Editing in ${label} — saves there will reload here.`, false);
        showExternalEditHint(label);
    }
    catch (e) {
        showDraftStatus(`Edit-in-Word failed: ${e?.message || e}`, true);
    }
});
// Editor help button — opens a reference modal with shortcuts and the
// Quill / tiptap differences. Source content lives in app/docs/editor.md
// (and is mirrored in editor-help.ts for runtime embed).
document.getElementById("btn-compose-help")?.addEventListener("click", async () => {
    try {
        const { EDITOR_HELP_MD } = await import("./editor-help.js");
        showEditorHelpModal(EDITOR_HELP_MD);
    }
    catch (e) {
        showDraftStatus(`Couldn't open help: ${e?.message || e}`, true);
    }
});
/** Render a markdown string in a centered modal. Click outside / Esc /
 *  Close button dismiss. Lightweight markdown rendering — handles headings,
 *  paragraphs, lists, tables, inline code, bold/italic. Intentionally
 *  doesn't pull a full markdown library since this is one document. */
function showEditorHelpModal(md) {
    if (document.getElementById("mailx-editor-help-modal"))
        return;
    const backdrop = document.createElement("div");
    backdrop.id = "mailx-editor-help-modal";
    backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10001;display:flex;align-items:center;justify-content:center;padding:24px;";
    const panel = document.createElement("div");
    panel.style.cssText = "background:var(--color-bg, #fff);color:var(--color-text, #000);border-radius:8px;max-width:780px;max-height:88vh;width:100%;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.4);";
    const header = document.createElement("div");
    header.style.cssText = "display:flex;justify-content:space-between;align-items:center;padding:12px 18px;border-bottom:1px solid var(--color-border, #ddd);";
    header.innerHTML = `<span style="font-weight:600;">Editor help</span><button id="mailx-editor-help-close" style="background:none;border:0;font-size:18px;cursor:pointer;color:var(--color-text);padding:2px 8px;" aria-label="Close">&times;</button>`;
    const body = document.createElement("div");
    body.style.cssText = "flex:1;overflow:auto;padding:18px 22px;font:14px/1.55 system-ui;";
    body.innerHTML = renderMarkdownLite(md);
    panel.appendChild(header);
    panel.appendChild(body);
    backdrop.appendChild(panel);
    document.body.appendChild(backdrop);
    const close = () => {
        backdrop.remove();
        document.removeEventListener("keydown", onKey, true);
    };
    const onKey = (e) => {
        if (e.key === "Escape") {
            e.stopPropagation();
            e.preventDefault();
            close();
        }
    };
    document.addEventListener("keydown", onKey, true);
    header.querySelector("#mailx-editor-help-close")?.addEventListener("click", close);
    backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
        close(); });
}
/** Compact markdown renderer. Handles headings, paragraphs, code blocks,
 *  inline code, bold, italic, links, lists, and pipe-tables. Built for the
 *  editor help doc shape — not a general-purpose markdown engine. */
function renderMarkdownLite(md) {
    const esc = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
    const inline = (s) => esc(s)
        .replace(/`([^`]+)`/g, '<code style="background:var(--color-bg-surface,#f3f3f3);padding:1px 4px;border-radius:3px;">$1</code>')
        .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
        .replace(/(?<![*\w])\*([^*\n]+)\*(?!\w)/g, "<em>$1</em>")
        .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
    const lines = md.split(/\r?\n/);
    const out = [];
    let i = 0;
    while (i < lines.length) {
        const line = lines[i];
        const h = /^(#{1,6})\s+(.*)$/.exec(line);
        if (h) {
            out.push(`<h${h[1].length} style="margin:18px 0 8px 0;">${inline(h[2])}</h${h[1].length}>`);
            i++;
            continue;
        }
        if (/^\s*$/.test(line)) {
            i++;
            continue;
        }
        // Pipe-table
        if (line.includes("|") && /^\s*\|/.test(line)) {
            const rows = [];
            while (i < lines.length && /^\s*\|/.test(lines[i])) {
                rows.push(lines[i].trim().split("|").slice(1, -1).map(s => s.trim()));
                i++;
            }
            if (rows.length >= 2 && /^[\s\-:]+$/.test(rows[1].join(""))) {
                const head = rows[0];
                const data = rows.slice(2);
                out.push(`<table style="border-collapse:collapse;margin:8px 0;">`);
                out.push(`<thead><tr>${head.map(h => `<th style="border-bottom:2px solid var(--color-border,#ccc);padding:4px 10px;text-align:left;">${inline(h)}</th>`).join("")}</tr></thead>`);
                out.push(`<tbody>${data.map(r => `<tr>${r.map(c => `<td style="border-bottom:1px solid var(--color-border,#eee);padding:4px 10px;">${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody>`);
                out.push(`</table>`);
                continue;
            }
        }
        // Bullet / numbered list
        if (/^\s*[-*]\s+/.test(line)) {
            const items = [];
            while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
                items.push(lines[i].replace(/^\s*[-*]\s+/, ""));
                i++;
            }
            out.push(`<ul style="margin:6px 0 6px 22px;">${items.map(it => `<li style="margin:3px 0;">${inline(it)}</li>`).join("")}</ul>`);
            continue;
        }
        // Default: paragraph
        out.push(`<p style="margin:6px 0;">${inline(line)}</p>`);
        i++;
    }
    return out.join("\n");
}
/** Modal hint shown when Edit-in-Word launches. The user is about to lose
 *  focus to Word and may not know what to do next, so spell it out: save in
 *  Word → switch back here → click Send. Dismissed by Esc, the close button,
 *  or any click outside the panel. */
function showExternalEditHint(editorLabel) {
    if (document.getElementById("mailx-extedit-hint"))
        return; // don't stack
    const backdrop = document.createElement("div");
    backdrop.id = "mailx-extedit-hint";
    backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10001;display:flex;align-items:center;justify-content:center;";
    const panel = document.createElement("div");
    panel.style.cssText = "background:var(--color-bg, #fff);color:var(--color-text, #000);border:1px solid var(--color-border, #ccc);border-radius:8px;padding:18px 22px;max-width:480px;box-shadow:0 8px 32px rgba(0,0,0,0.4);font:14px/1.5 system-ui;";
    panel.innerHTML = `
        <div style="font-weight:600;font-size:16px;margin-bottom:10px;">Editing in ${escapeHtml(editorLabel)}</div>
        <ol style="margin:0 0 12px 18px;padding:0;">
            <li>Edit your message in <b>${escapeHtml(editorLabel)}</b>.</li>
            <li>Save (<b>Ctrl+S</b>). Today, choose <b>"Web Page, Filtered (.htm)"</b> if Word asks for a format — keep the same filename.</li>
            <li>Switch back to this window (<b>Alt+Tab</b>). The body will reload here.</li>
            <li>Click <b>Send</b> in this window when ready.</li>
        </ol>
        <div style="text-align:right;margin-top:8px;">
            <button type="button" id="mailx-extedit-hint-ok" style="padding:6px 16px;border:1px solid var(--color-border, #ccc);background:var(--color-bg-surface, #f6f6f6);border-radius:4px;cursor:pointer;font:inherit;">Got it</button>
        </div>
    `;
    backdrop.appendChild(panel);
    document.body.appendChild(backdrop);
    const close = () => {
        backdrop.remove();
        document.removeEventListener("keydown", onKey, true);
    };
    const onKey = (e) => {
        if (e.key === "Escape") {
            e.stopPropagation();
            e.preventDefault();
            close();
        }
    };
    document.addEventListener("keydown", onKey, true);
    panel.querySelector("#mailx-extedit-hint-ok")?.addEventListener("click", close);
    // Click outside the panel (i.e. on the backdrop) dismisses.
    backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
        close(); });
}
// Listen for external-editor saves. Only react to events for this compose's
// editId — multiple compose windows can be open and should not stomp each
// other's bodies.
onEvent((ev) => {
    if (ev?.type !== "wordEditUpdated")
        return;
    if (!wordEditId || ev.editId !== wordEditId)
        return;
    try {
        editor.setHtml(ev.html || "");
        showDraftStatus("Reloaded edits from external editor.", false);
        scheduleDraftSave();
    }
    catch (e) {
        showDraftStatus(`Reload failed: ${e?.message || e}`, true);
    }
});
window.addEventListener("beforeunload", () => {
    if (wordEditId)
        closeWordEdit(wordEditId).catch(() => { });
});
async function ingestFiles(files) {
    for (const file of Array.from(files)) {
        const buf = await file.arrayBuffer();
        // base64 the whole thing — mailx-service builds the multipart/mixed
        let binary = "";
        const bytes = new Uint8Array(buf);
        for (let i = 0; i < bytes.length; i++)
            binary += String.fromCharCode(bytes[i]);
        const dataBase64 = btoa(binary);
        attachments.push({
            filename: file.name,
            mimeType: file.type || "application/octet-stream",
            size: file.size,
            dataBase64,
        });
    }
    renderAttachmentChips();
    scheduleDraftSave();
}
fileInput?.addEventListener("change", async () => {
    if (!fileInput.files)
        return;
    await ingestFiles(fileInput.files);
    fileInput.value = "";
});
// Drag-and-drop: dropping files anywhere on the compose window attaches them.
// Highlights a subtle overlay while dragging so the target is obvious. The
// editor iframe swallows drag events internally so we attach to the compose
// document root; Quill's own paste/drop handling doesn't fight us because
// files-with-no-HTML-or-text dragover never hits Quill's clipboard module.
(() => {
    let dragDepth = 0;
    const root = document.body;
    const overlay = document.createElement("div");
    overlay.id = "compose-drop-overlay";
    // Toggle `display` directly — can't use the `hidden` attribute here
    // because the inline `display` property in cssText outranks it, which is
    // why the overlay showed permanently when I used `overlay.hidden = true`
    // (user-reported 2026-04-24 with screenshot — blue tint + dashed border
    // were visible before any drag started).
    const baseStyle = "position:fixed;inset:0;background:oklch(0.6 0.18 250 / 0.15);border:3px dashed oklch(0.55 0.2 250);z-index:9999;pointer-events:none;align-items:center;justify-content:center;font-size:1.5rem;font-weight:500;color:oklch(0.35 0.2 250)";
    overlay.style.cssText = baseStyle + ";display:none";
    overlay.textContent = "Drop files to attach";
    root.appendChild(overlay);
    const show = () => { overlay.style.display = "flex"; };
    const hide = () => { overlay.style.display = "none"; };
    const hasFiles = (e) => Array.from(e.dataTransfer?.types || []).includes("Files");
    root.addEventListener("dragenter", (e) => {
        if (!hasFiles(e))
            return;
        dragDepth++;
        show();
    });
    root.addEventListener("dragleave", (e) => {
        if (!hasFiles(e))
            return;
        dragDepth = Math.max(0, dragDepth - 1);
        if (dragDepth === 0)
            hide();
    });
    root.addEventListener("dragover", (e) => {
        if (!hasFiles(e))
            return;
        e.preventDefault(); // required so drop fires
        if (e.dataTransfer)
            e.dataTransfer.dropEffect = "copy";
    });
    root.addEventListener("drop", async (e) => {
        if (!hasFiles(e))
            return;
        e.preventDefault();
        dragDepth = 0;
        hide();
        const files = e.dataTransfer?.files;
        if (files && files.length > 0)
            await ingestFiles(files);
    });
})();
// ── Save and close (X button from parent) ──
window.addEventListener("compose-save-and-close", () => {
    handleCloseRequest();
});
// ── Discard (trash icon from parent title bar) ──
// Routes through the existing btn-discard handler so the confirm dialog and
// draft-deletion logic stay in one place.
window.addEventListener("compose-discard", () => {
    document.getElementById("btn-discard")?.click();
});
// ── Keyboard shortcuts ──
document.addEventListener("keydown", (e) => {
    if (e.ctrlKey && e.key === "Enter") {
        document.getElementById("btn-send")?.click();
    }
    if (e.key === "Escape") {
        // If the user just clicked Attach, the native file picker is up.
        // The picker swallows the Esc that dismissed it, but the keydown can
        // still bubble here on WebView2 — closing the whole compose. Suppress
        // for a short window after the attach click.
        if (Date.now() - attachJustClicked < 1500)
            return;
        // A modal/dialog open over compose (the link editor, TinyMCE's
        // dialogs, the add-to-preferred modal, …) owns Escape — dismissing
        // the dialog must NOT also bubble here and offer to close the whole
        // compose window. `e.target.closest` still resolves even when the
        // dialog removed itself in an earlier event phase: the detached
        // subtree stays intact, so `.closest()` still walks it.
        const t = e.target;
        if (t && typeof t.closest === "function"
            && t.closest('.mailx-modal-backdrop, .tox-dialog, [role="dialog"]')) {
            return;
        }
        e.preventDefault();
        handleCloseRequest();
    }
    // Ctrl+K in an address field = trigger address completion.
    // NOTE: Ctrl+K is ALSO the editor's "insert link" shortcut. Scope this handler
    // strictly to the to/cc/bcc inputs so it doesn't shadow the editor binding when
    // focus is in the body.
    if (e.ctrlKey && (e.key === "k" || e.key === "K")) {
        const active = document.activeElement;
        const addressFields = [toInput, ccInput, bccInput];
        if (addressFields.includes(active)) {
            e.preventDefault();
            active.dispatchEvent(new Event("input"));
        }
    }
});
//# sourceMappingURL=compose.js.map