/**
 * 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, type MailxEditor } from "./editor.js";
import { getVersion, getSettings, getAccounts, searchContacts, sendMessage, 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 as any).mailxVersion || "?" });

/** Close compose window */
function closeCompose(): void {
    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 { /* */ }
}

interface ComposeInit {
    mode: string;
    accountId: string;
    to: { name: string; address: string }[];
    cc: { name: string; address: string }[];
    subject: string;
    bodyHtml: string;
    inReplyTo: string;
    references: string[];
    accounts: { id: string; name: string; email: string }[];
    fromAddress?: string;
    draftUid?: number;
    draftFolderId?: number;
}

// ── Load editor scripts dynamically ──

function loadScript(src: string): Promise<void> {
    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: string): void {
    const link = document.createElement("link");
    link.rel = "stylesheet";
    link.href = href;
    document.head.appendChild(link);
}

async function loadEditorAssets(type: "quill" | "tiptap"): Promise<void> {
    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
        loadCSS("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css");
        await loadScript("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js");
    }
}

// ── Determine editor type from settings ──
//
// Compose must open fast. The previous flow awaited getVersion() then
// getSettings() sequentially before the editor was even loaded — any
// service-side stall (busy sync, slow IMAP, hung OAuth refresh) turned
// "click Reply" into a multi-second / multi-minute wait with a blank
// compose window. Local-first: read the editor-type preference from a
// tiny localStorage cache that we update whenever getSettings succeeds
// in the background. Default to quill on first run / cache miss.
let editorType: "quill" | "tiptap" = "quill";
let appSettings: any = null;
try {
    const cached = localStorage.getItem("mailx-editor-type");
    if (cached === "tiptap" || cached === "quill") editorType = cached;
} catch { /* private-mode / SecurityError — default quill */ }
// Refresh the cache asynchronously — doesn't block compose open.
(async () => {
    try {
        appSettings = await getSettings();
        const next = appSettings?.ui?.editor === "tiptap" ? "tiptap" : "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 */ }
})();

// Whatever happens in editor init, surface failures to the mailx log
// AND fall through to a plain-contenteditable fallback so the rest of
// the compose script (Discard, X, Send, save-draft) still runs. Earlier
// versions re-threw asset-load failures, which left compose with dead
// buttons and the user with no recourse — exactly the "Reply window
// won't close" symptom we hit when Quill's CDN was unreachable.
let editor: MailxEditor;
let editorAssetError: any = null;
try {
    await loadEditorAssets(editorType);
} catch (e: any) {
    editorAssetError = e;
    logClientEvent("compose-editor-assets-failed", { type: editorType, error: String(e?.message || e) });
}
const container = document.getElementById("compose-editor")!;
container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
try {
    if (editorAssetError) throw editorAssetError;
    editor = await createEditor(container, editorType);
} catch (e: any) {
    logClientEvent("compose-editor-create-failed", { type: editorType, error: String(e?.message || e) });
    // Render a minimal contenteditable fallback so the user can still type
    // SOMETHING. Without this, an editor failure leaves the compose form
    // half-functional (To/Cc/Bcc work, body doesn't) and the user doesn't
    // know why. The fallback is a plain div — no toolbar, no rich text.
    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<HTMLElement>(".compose-fallback-editor")!;
    editor = {
        root: fallback,
        setHtml: (html: string) => { fallback.innerHTML = html; },
        getHtml: () => fallback.innerHTML,
        getText: () => fallback.innerText,
        focus: () => fallback.focus(),
        setCursor: () => { /* no-op */ },
        getScrollContainer: () => fallback,
        onContentChange: (handler: () => void) => { fallback.addEventListener("input", handler); },
        onKeyDown: (handler: (e: KeyboardEvent) => void) => { fallback.addEventListener("keydown", handler); },
        insertTextAtCursor: (text: string) => {
            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));
            }
        },
    };
    // Surface the failure to the user in the status bar so they know
    // why the toolbar is missing and the editor is plain.
    setTimeout(() => showDraftStatus(`Editor failed to load (${editorType}). Plain-text fallback in use. Open log for details.`, true), 0);
}

// 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;
    let zoom = parseFloat(localStorage.getItem(STORAGE_KEY) || "1") || 1;
    const applyZoom = () => {
        container.style.fontSize = `${zoom}em`;
        localStorage.setItem(STORAGE_KEY, String(zoom));
    };
    applyZoom();
    container.addEventListener("wheel", (e: WheelEvent) => {
        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: KeyboardEvent) => {
        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") as HTMLInputElement;
const fromOptions = document.getElementById("compose-from-options") as HTMLDataListElement;
const toInput = document.getElementById("compose-to") as HTMLInputElement;
const ccInput = document.getElementById("compose-cc") as HTMLInputElement;
const bccInput = document.getElementById("compose-bcc") as HTMLInputElement;
const subjectInput = document.getElementById("compose-subject") as HTMLInputElement;

/** Registered accounts — populated once at init time, used to map the From
 *  input value back to an account id on send. */
interface ComposeAccount { id: string; name: string; label?: string; email: string; defaultSend?: boolean; }
let knownAccounts: ComposeAccount[] = [];

// ── 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(() => { /* autocomplete unavailable */ });
}

/** Format an account for the From field: "Name <email>". */
function formatAccountFrom(acct: ComposeAccount): string {
    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(): string[] {
    try { return JSON.parse(localStorage.getItem(FROM_HISTORY_KEY) || "[]"); } catch { return []; }
}
function recordFromHistory(value: string): void {
    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: ComposeAccount[], selectedId?: string): void {
    knownAccounts = accounts;
    fromOptions.innerHTML = "";
    const seenValues = new Set<string>();
    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(): { name: string; address: string } {
    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(): string {
    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(): string {
    return fromInput.value.trim();
}

/** Smart tab — skip to next empty field, ending at body */
function smartTab(current: HTMLInputElement): void {
    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: MouseEvent,
    row: { name: string; email: string; source: string },
): void {
    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: any) {
                    alert(`Failed to add to denylist: ${err?.message || err}`);
                }
            },
        },
    ]);
}

function openAddToPreferredModal(prefill: { name: string; email: string; source: string }): void {
    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") as HTMLInputElement).value = prefill.name || "";
    (overlay.querySelector("#pf-email") as HTMLInputElement).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") as HTMLInputElement).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") as HTMLInputElement).value.trim();
        const email = (overlay.querySelector("#pf-email") as HTMLInputElement).value.trim();
        const source = (overlay.querySelector("#pf-source") as HTMLInputElement).value.trim();
        const org = (overlay.querySelector("#pf-org") as HTMLInputElement).value.trim();
        if (!email) { alert("Email is required."); return; }
        try {
            await addPreferredContact({ name, email, source, organization: org });
            close();
        } catch (err: any) {
            alert(`Failed to save: ${err?.message || err}`);
        }
    });
    (overlay.querySelector("#pf-name") as HTMLInputElement).focus();
}

function setupAutocomplete(input: HTMLInputElement): void {
    let dropdown: HTMLDivElement | null = null;
    let activeIndex = -1;
    let debounce: ReturnType<typeof setTimeout>;

    function closeDropdown(): void {
        if (dropdown) { dropdown.remove(); dropdown = null; }
        activeIndex = -1;
    }

    function getLastToken(): string {
        const val = input.value;
        const lastComma = val.lastIndexOf(",");
        return val.substring(lastComma + 1).trim();
    }

    function replaceLastToken(replacement: string): void {
        const val = input.value;
        const lastComma = val.lastIndexOf(",");
        const prefix = lastComma >= 0 ? val.substring(0, lastComma + 1) + " " : "";
        input.value = prefix + replacement + ", ";
        closeDropdown();
        input.focus();
    }

    input.addEventListener("input", () => {
        clearTimeout(debounce);
        const token = getLastToken();
        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) as { name: string; email: string; source: string; useCount: number }[];
                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 item = document.createElement("div");
                    item.className = `ac-item${i === 0 ? " ac-active" : ""}`;

                    const nameEl = document.createElement("span");
                    nameEl.className = "ac-item-name";
                    nameEl.textContent = results[i].name || results[i].email;

                    const emailEl = document.createElement("span");
                    emailEl.className = "ac-item-email";
                    emailEl.textContent = results[i].email;

                    // Source badge — shows where this row came from. Custom
                    // user tags from contacts.jsonc preferred entries
                    // (`source: "work"`, `source: "family"`) flow through
                    // verbatim; system sources show as 'google' / 'discovered'
                    // / 'preferred'. Helps disambiguate two rows with the
                    // same email but different names (Bob's wife vs Bob Smith).
                    const sourceEl = document.createElement("span");
                    sourceEl.className = "ac-item-source";
                    sourceEl.textContent = results[i].source || "";

                    item.appendChild(nameEl);
                    if (results[i].name) item.appendChild(emailEl);
                    if (results[i].source) item.appendChild(sourceEl);

                    item.addEventListener("mousedown", (e) => {
                        e.preventDefault();
                        const display = results[i].name
                            ? `${results[i].name} <${results[i].email}>`
                            : results[i].email;
                        replaceLastToken(display);
                    });

                    // Right-click → contextual actions on this autocomplete
                    // row. Two paths: promote to preferred (writes to
                    // contacts.jsonc#preferred[]) or denylist (writes to
                    // contacts.jsonc#denylist[] and purges any matching
                    // discovered rows). Both round-trip through cloudWrite.
                    item.addEventListener("contextmenu", (e) => {
                        e.preventDefault();
                        e.stopPropagation();
                        showAutocompleteContextMenu(e as MouseEvent, results[i]);
                    });

                    dropdown.appendChild(item);
                }

                input.parentElement!.appendChild(dropdown);
            } catch { /* ignore */ }
            });
        }, 200);
    });

    input.addEventListener("keydown", (e) => {
        if (!dropdown) return;
        const items = dropdown.querySelectorAll(".ac-item");

        if (e.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 (e.key === "ArrowUp") {
            e.preventDefault();
            activeIndex = Math.max(activeIndex - 1, 0);
            items.forEach((el, i) => el.classList.toggle("ac-active", i === activeIndex));
        } else if (e.key === "Tab" || e.key === "Enter") {
            if (items.length > 0) {
                e.preventDefault();
                const idx = activeIndex >= 0 ? activeIndex : 0;
                (items[idx] as HTMLElement).dispatchEvent(new MouseEvent("mousedown"));
                // Stay in field — user may want to add more addresses
                return;
            }
        } else if (e.key === "Escape") {
            closeDropdown();
        }
    });

    input.addEventListener("blur", () => {
        setTimeout(closeDropdown, 150);
    });
}

setupAutocomplete(toInput);
setupAutocomplete(ccInput);
setupAutocomplete(bccInput);

function formatAddrs(addrs: { name: string; address: string }[]): string {
    return addrs.map(a => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
}

function parseAddrs(s: string): { name: string; address: string }[] {
    if (!s.trim()) return [];
    // Split on commas and drop empty segments. This handles trailing commas
    // ("foo@x.com,") and stray whitespace ("foo@x.com, ,bar@y.com") without
    // producing phantom empty addresses that fail validation on send.
    return s.split(",")
        .map(p => p.trim())
        .filter(p => p.length > 0)
        .map(part => {
            const match = part.match(/^(.+?)\s*<(.+?)>$/);
            if (match) return { name: match[1].trim(), address: match[2].trim() };
            return { name: "", address: part };
        });
}

function applyInit(init: ComposeInit): void {
    // 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;

    // 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(() => { /* non-fatal hint */ });
                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(() => { /* non-fatal hint */ });
            });
        }
    }

    // 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 || "";
    const acct: any = init.accounts.find(a => a.id === init.accountId);
    const isNew = init.mode !== "reply" && init.mode !== "replyAll"
        && init.mode !== "forward" && init.mode !== "draft" && !init.draftUid;
    const isReplyForward = init.mode === "reply" || init.mode === "replyAll"
        || init.mode === "forward";

    if (isNew && acct?.sig?.text) {
        const sigText = acct.sig.html
            ? acct.sig.text  // future: trust as raw HTML
            : escapeHtml(acct.sig.text).replace(/\n/g, "<br>");
        bodyToRender = `${bodyToRender}<br><br>-- <br>${sigText}`;
    } else if (acct?.signature && init.mode !== "draft" && !init.draftUid) {
        const sigBlock = `<br><br>--<br>${acct.signature}`;
        bodyToRender = isReplyForward
            ? `<br>${sigBlock}<br>${bodyToRender}`   // sig above the quote
            : `${bodyToRender}${sigBlock}`;          // sig at the end for new
    }
    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;
    }

    setComposeTitle(init.subject || "");

    // Focus first empty field: To → Subject → body
    if (!toInput.value) toInput.focus();
    else if (!subjectInput.value) subjectInput.focus();
    else editor.focus();
}

// Q68: dirty marker (•) in the window title until the next successful save.
let composeDirty = false;
function setComposeTitle(subject: string): void {
    const base = subject ? `${subject} - Compose` : "Compose - mailx";
    document.title = composeDirty ? `• ${base}` : base;
}
function markComposeDirty(): void {
    if (composeDirty) return;
    composeDirty = true;
    setComposeTitle(subjectInput?.value || "");
}
function markComposeClean(): void {
    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 = 1500;  // save ~1.5s after the last keystroke
const DRAFT_INTERVAL_MS = 5000;        // safety-net interval save
let draftUid: number | null = null;
let draftId: string | null = null; // stable ID for dedup when APPENDUID unavailable
let draftTimer: ReturnType<typeof setInterval> | null = null;
let draftDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let lastDraftContent = "";
let draftSaving = false; // prevent concurrent saves
let draftSaveFailed = false; // surfaced in the compose status tag

interface PendingAttachment {
    filename: string;
    mimeType: string;
    size: number;
    dataBase64: string;
}
const attachments: PendingAttachment[] = [];

function showDraftStatus(text: string, isError: boolean): void {
    const status = document.getElementById("compose-status");
    if (!status) return;
    status.textContent = text;
    status.classList.toggle("compose-status-error", isError);
}

async function saveDraft(): Promise<void> {
    if (draftSaving) return; // previous save still in flight
    const content = editor.getHtml() + subjectInput.value + toInput.value;
    if (content === lastDraftContent) return; // no changes
    if (!editor.getText().trim() && !subjectInput.value && !toInput.value) return; // empty
    // Expose to window for blur-handler.
    (window as any).__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: any) {
        // 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(): void {
    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=[]).

(async () => {
    const stored = sessionStorage.getItem("composeInit");
    if (stored) {
        sessionStorage.removeItem("composeInit");
        const init = JSON.parse(stored) as ComposeInit;
        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);
            getAccounts().then((fresh: any[]) => {
                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(() => { /* non-fatal */ });
        } else {
            // Edit Draft / other callers that didn't pre-fill accounts.
            // Have to wait on getAccounts here — the From dropdown needs it.
            let fresh: any[] = [];
            try { fresh = await getAccounts(); } catch (e: any) { console.error("Failed to load accounts:", e); }
            init.accounts = fresh;
            applyInit(init);
        }
    } else {
        let accounts: any[] = [];
        try { accounts = await getAccounts(); } catch (e: any) { 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);

    // 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: KeyboardEvent) => {
    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 as any).__mailxSaveDraft?.(); } catch { /* */ }
});

document.getElementById("btn-send")?.addEventListener("click", () => {
    // 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");
    const body = {
        from: getFromAccountId(),
        fromAddress: getFromAddress(),
        to: parseAddrs(toInput.value),
        cc: parseAddrs(ccInput.value),
        bcc: parseAddrs(bccInput.value),
        subject: subjectInput.value,
        bodyHtml: editor.getHtml(),
        bodyText: editor.getText(),
        attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
    };
    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;
    }
    console.log(`[compose] Send clicked: from=${body.from} to=${JSON.stringify(body.to)} subject="${body.subject}" attachments=${body.attachments.length}`);
    // Wait for the IPC round-trip before closing compose. The IPC is supposed
    // to be <100ms (Node validates + disk-writes synchronously inside send()
    // then returns). If it throws OR takes too long, the user keeps their
    // typed message and sees the error inline instead of losing it to a
    // fire-and-forget that never landed. Earlier fire-and-forget version lost
    // messages silently when anything upstream of disk write broke.
    const sendBtn = document.getElementById("btn-send") as HTMLButtonElement | null;
    if (sendBtn) { sendBtn.disabled = true; sendBtn.textContent = "Sending…"; }
    const statusEl = document.getElementById("compose-status");
    if (statusEl) statusEl.textContent = "";
    const sendStart = Date.now();
    logClientEvent("compose-send-pre-ipc");
    // Parent-window relay for send. Empirical observation: Android (direct
    // in-process SMTP) sends reliably; desktop (iframe → parent.mailxapi →
    // msger → Node → service) has sendMessage IPCs failing to reach Node
    // for reasons still unknown (iframe bridge behaves differently from the
    // top frame in msger's WebView2 for this specific call). Meanwhile the
    // parent window's bridge is proven — getAccounts / getOutboxStatus run
    // through it every few seconds with no failures.
    //
    // Fix: the iframe doesn't touch the bridge at all for send. It posts
    // a request to the parent, and the parent calls the real sendMessage
    // from its own frame. Parent posts the result back. This bypasses
    // whatever is wrong with iframe-scoped IPC.
    const ipcPromise = new Promise<void>((resolve, reject) => {
        const reqId = `send-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
        const timer = setTimeout(() => {
            window.removeEventListener("message", onMsg);
            reject(new Error("parent-relay send timeout (120s)"));
        }, 120000);
        const onMsg = (ev: MessageEvent) => {
            if (!ev.data || ev.data.type !== "mailx-compose-send-result" || ev.data.id !== reqId) return;
            clearTimeout(timer);
            window.removeEventListener("message", onMsg);
            if (ev.data.ok) resolve();
            else reject(new Error(ev.data.error || "unknown"));
        };
        window.addEventListener("message", onMsg);
        try {
            parent.postMessage({ type: "mailx-compose-send", id: reqId, body }, "*");
            logClientEvent("compose-send-ipc-invoked", { via: "parent-relay", reqId });
        } catch (e: any) {
            clearTimeout(timer);
            window.removeEventListener("message", onMsg);
            reject(e);
        }
    });
    Promise.resolve(ipcPromise)
        .then(() => {
            logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
            console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
            // Record From-address history on successful send. Only manual
            // values worth keeping — skip anything that exactly matches a
            // known account (already in the dropdown), and skip obviously
            // invalid inputs. Populated dropdown surfaces this next time.
            try {
                const raw = fromInput.value.trim();
                const known = knownAccounts.some(a => formatAccountFrom(a) === raw);
                if (raw && !known && /@.+\./.test(raw)) recordFromHistory(raw);
            } catch { /* */ }
            // Stop autosave only after ACK — if send threw we want the draft
            // autosave to keep the message safe.
            if (draftTimer) { clearInterval(draftTimer); draftTimer = null; }
            if (draftUid || draftId) {
                deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => {});
            }
            closeCompose();
        })
        .catch((e: any) => {
            const msg: string = e?.message || String(e);
            logClientEvent("compose-send-ipc-rejected", { error: msg, ms: Date.now() - sendStart });
            console.error(`[compose] Send IPC failed after ${Date.now() - sendStart}ms: ${msg}`);
            if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = "Send"; }
            if (statusEl) statusEl.textContent = `Send failed: ${msg}`;
            try {
                parent.postMessage({ type: "mailx-send-error", message: msg, accountId: body.from }, "*");
            } catch { /* */ }
        });
});

// ── Close handling ──

/** True if the compose has anything worth asking about. */
function composeHasContent(): boolean {
    return !!(editor.getText().trim() || toInput.value.trim() || ccInput.value.trim() || bccInput.value.trim() || subjectInput.value.trim());
}

/** 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(): Promise<"save" | "discard" | "cancel"> {
    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: string, choice: "save" | "discard" | "cancel", primary: boolean): HTMLButtonElement => {
            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 = (): void => {
            document.removeEventListener("keydown", onKey);
            overlay.remove();
        };
        const onKey = (e: KeyboardEvent): void => {
            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 as HTMLButtonElement).focus();
    });
}

/** Handle any "close the compose" action (Discard button, Escape, X, window close). */
async function handleCloseRequest(): Promise<boolean> {
    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: if we have a tracked draft, delete it so the orphan doesn't stick around.
        if (draftUid || draftId) {
            try { await deleteDraft(getFromAccountId(), draftUid || 0, draftId || ""); } catch { /* ignore */ }
        }
    }
    closeCompose();
    return true;
}

document.getElementById("btn-discard")?.addEventListener("click", () => {
    handleCloseRequest();
});

// ── Cc / Bcc toggle ──
const ccRow = document.getElementById("compose-cc-row") as HTMLElement;
const bccRow = document.getElementById("compose-bcc-row") as HTMLElement;
const toggleCcBtn = document.getElementById("btn-toggle-cc") as HTMLButtonElement;
const toggleBccBtn = document.getElementById("btn-toggle-bcc") as HTMLButtonElement;

function setCcVisible(visible: boolean): void {
    ccRow.hidden = !visible;
    toggleCcBtn.classList.toggle("active", visible);
    if (visible) ccInput.focus();
    else ccInput.value = "";
}
function setBccVisible(visible: boolean): void {
    bccRow.hidden = !visible;
    toggleBccBtn.classList.toggle("active", visible);
    if (visible) bccInput.focus();
    else bccInput.value = "";
}
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") as HTMLInputElement;
const attEl = document.getElementById("compose-attachments") as HTMLElement;

function renderAttachmentChips(): void {
    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: string): string {
    return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
}
function formatSize(n: number): string {
    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: string | null = null;
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);
    } catch (e: any) {
        showDraftStatus(`Edit-in-Word failed: ${e?.message || e}`, true);
    }
});

// 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: any) => {
    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: any) {
        showDraftStatus(`Reload failed: ${e?.message || e}`, true);
    }
});
window.addEventListener("beforeunload", () => {
    if (wordEditId) closeWordEdit(wordEditId).catch(() => { /* */ });
});

async function ingestFiles(files: FileList | File[]): Promise<void> {
    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: DragEvent) =>
        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();
});

// ── 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;
        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 as HTMLElement;
        const addressFields: HTMLElement[] = [toInput, ccInput, bccInput];
        if (addressFields.includes(active)) {
            e.preventDefault();
            (active as HTMLInputElement).dispatchEvent(new Event("input"));
        }
    }
});
