/**
 * Message viewer component -- displays full message in sandboxed iframe.
 *
 * Passive renderer. The list owns "what's focused"; this module exposes
 * `showMessage(accountId, uid, folderId, envelope?)` and `clearViewer()`
 * as the only ways content reaches the pane. There is no subscription —
 * if the list never tells the viewer about a focus change, the viewer
 * simply doesn't update. That's the property that makes summary↔preview
 * drift impossible: there is exactly one path into this pane.
 */

import { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick, addPreferredContact } from "../lib/api-client.js";
import { showContextMenu } from "./context-menu.js";
import type { MenuItem } from "./context-menu.js";
import type { ListMessage } from "../lib/message-state.js";
import { updateMessageFlags as stateUpdateFlags } from "../lib/message-state.js";

/** Currently displayed message (for reply/forward) */
let currentMessage: any = null;
let currentAccountId: string = "";
let showMessageGeneration = 0; // Cancel stale fetches
let retryCount = 0;
/** Last envelope handed in by the list — used for envelope-first paint
 *  on retry, where we don't have a fresh row click to pass it again. */
let lastEnvelope: ListMessage | null = null;

export function getCurrentMessage(): { accountId: string; message: any } | null {
    if (!currentMessage) return null;
    return { accountId: currentAccountId, message: currentMessage };
}

/** Kept as an exported no-op for back-compat with app.ts wiring. The
 *  state-subscribe model is gone; list now drives the viewer directly. */
export function initViewer(): void { /* no-op: list owns focus */ }

// Zoom is persisted across messages via localStorage
const ZOOM_KEY = "mailx-preview-zoom";
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3.0;
const ZOOM_STEP = 0.1;
let previewZoom = clampZoom(parseFloat(localStorage.getItem(ZOOM_KEY) || "1"));

function clampZoom(z: number): number {
    if (!Number.isFinite(z) || z <= 0) return 1;
    return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(z * 100) / 100));
}

function applyZoom(doc: Document): void {
    // Zoom lives on <html>, not <body>, because WebView2/Chromium's scroll
    // container is the root element — body.style.zoom leaves the scroll
    // container out of sync with the zoomed content, breaking scrollbar
    // display and wheel scrolling. documentElement.style.zoom keeps them
    // aligned so the iframe scrolls normally at any zoom level.
    if (doc.documentElement) (doc.documentElement.style as any).zoom = String(previewZoom);
}

function setZoom(z: number, doc: Document): void {
    previewZoom = clampZoom(z);
    localStorage.setItem(ZOOM_KEY, String(previewZoom));
    applyZoom(doc);
}

/** Install preview iframe controls: key forwarding to parent, Ctrl+wheel zoom,
 *  keyboard zoom shortcuts (Ctrl+= / Ctrl+- / Ctrl+0), and the right-click menu. */
/** Run AI translate on `text` and show result in a small modal. Disabled
 *  by default — user enables via Settings (translateEnabled in
 *  AutocompleteSettings). When disabled, the modal explains how to enable. */
async function translateAndShow(text: string): Promise<void> {
    if (!text.trim()) return;
    const status = document.getElementById("status-sync");
    if (status) status.textContent = "Translating…";

    const overlay = document.createElement("div");
    overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;";
    const modal = document.createElement("div");
    modal.style.cssText = "background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;width:560px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;box-shadow:0 4px 24px rgba(0,0,0,0.3);";
    const header = document.createElement("div");
    header.style.cssText = "padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;display:flex;justify-content:space-between;align-items:center;";
    header.innerHTML = `<span>Translation</span><button style="cursor:pointer;border:0;background:transparent;font-size:16px;" aria-label="Close">×</button>`;
    const body = document.createElement("div");
    body.style.cssText = "flex:1;overflow:auto;padding:12px 14px;white-space:pre-wrap;font-size:13px;line-height:1.45;";
    body.textContent = "Working…";
    modal.appendChild(header);
    modal.appendChild(body);
    overlay.appendChild(modal);
    document.body.appendChild(overlay);
    const close = () => overlay.remove();
    header.querySelector("button")?.addEventListener("click", close);
    overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
    document.addEventListener("keydown", function onKey(e: KeyboardEvent) {
        if (e.key === "Escape") { document.removeEventListener("keydown", onKey); close(); }
    });

    try {
        const { aiTransform } = await import("../lib/api-client.js");
        const r = await aiTransform({ action: "translate", text, targetLang: "en" });
        if (r.text) {
            body.textContent = r.text;
            if (status) status.textContent = "";
        } else {
            body.innerHTML = `<div style="color:var(--muted, #888);">No result.</div>` +
                `<div style="margin-top:8px;font-size:12px;color:var(--muted, #888);">${r.reason || ""}</div>` +
                `<div style="margin-top:14px;font-size:12px;">Enable AI translate in Settings → AI features (off by default).</div>`;
            if (status) status.textContent = `Translate: ${r.reason || "no result"}`;
        }
    } catch (err: any) {
        body.textContent = `Error: ${err?.message || String(err)}`;
        if (status) status.textContent = `Translate error: ${err?.message || ""}`;
    }
}

function installPreviewControls(iframe: HTMLIFrameElement): void {
    const attach = () => {
        const doc = iframe.contentDocument;
        if (!doc) return;

        applyZoom(doc);

        doc.addEventListener("keydown", (e) => {
            const target = e.target as HTMLElement | null;
            if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
            // Zoom is iframe-local — handle here, don't forward.
            if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); setZoom(previewZoom + ZOOM_STEP, doc); return; }
            if (e.ctrlKey && e.key === "-") { e.preventDefault(); setZoom(previewZoom - ZOOM_STEP, doc); return; }
            if (e.ctrlKey && e.key === "0") { e.preventDefault(); setZoom(1, doc); return; }
            // Forward EVERY keydown to the parent — no duplicated hotkey list.
            // If the parent's handler calls preventDefault (because it owns the
            // shortcut), dispatchEvent returns false, and we preventDefault on
            // the iframe side too so the browser doesn't ALSO act on it
            // (Ctrl+N otherwise pops a new browser window in some hosts).
            // Single source of truth = app.ts hotkey handlers. Plain typing in
            // the email body — letters, etc. — propagates with no parent
            // handler matching, so dispatchEvent returns true and the iframe
            // event is left alone.
            const synth = new KeyboardEvent("keydown", {
                key: e.key, code: e.code,
                ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
                bubbles: true, cancelable: true,
            });
            const allowDefault = document.dispatchEvent(synth);
            if (!allowDefault) e.preventDefault();
        });

        doc.addEventListener("wheel", (e) => {
            if (!e.ctrlKey) return;
            e.preventDefault();
            setZoom(previewZoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP), doc);
        }, { passive: false });

        // Link interception lives in the iframe's own inline <script> (see
        // wrapHtmlBody). That script runs under a CSP nonce so email-body
        // scripts stay blocked while ours forwards taps to the parent frame.

        doc.addEventListener("contextmenu", (e) => {
            e.preventDefault();
            const me = e as MouseEvent;
            const rect = iframe.getBoundingClientRect();
            const x = rect.left + me.clientX;
            const y = rect.top + me.clientY;
            const pct = Math.round(previewZoom * 100);
            const sel = doc.defaultView?.getSelection();
            const selectedText = sel?.toString().trim() || "";
            const runSearch = (query: string): void => {
                const input = document.getElementById("search-input") as HTMLInputElement | null;
                if (!input) return;
                input.value = query;
                // Trigger the existing search path — Enter keydown hits the
                // immediate branch in app.ts's handler.
                input.dispatchEvent(new Event("input"));
                input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
                input.focus();
            };
            const items: MenuItem[] = [
                { label: "Copy", action: () => doc.execCommand("copy") },
                { label: "Select all", action: () => {
                    const s = doc.defaultView?.getSelection();
                    if (!s) return;
                    const range = doc.createRange();
                    range.selectNodeContents(doc.body);
                    s.removeAllRanges();
                    s.addRange(range);
                } },
            ];
            if (selectedText) {
                items.push(
                    { label: "", action: () => {}, separator: true },
                    // Truncate long selections in the label so the menu doesn't
                    // blow out; full string is what we search for.
                    { label: `Search messages for "${selectedText.length > 40 ? selectedText.slice(0, 40) + "…" : selectedText}"`,
                      action: () => runSearch(selectedText) },
                    {
                        label: "Copy as quoted (> prefix)",
                        action: async () => {
                            // Prefix each line with "> " (RFC 3676 reply-quote).
                            // Useful when pasting a snippet into a compose window
                            // without the usual full-message blockquote wrapping.
                            const quoted = selectedText.split(/\r?\n/).map(l => "> " + l).join("\n");
                            try { await navigator.clipboard.writeText(quoted); } catch { /* */ }
                        },
                    },
                );
            }
            const senderAddr = currentMessage?.from?.address || "";
            if (senderAddr) {
                items.push({
                    label: `Search messages from ${senderAddr}`,
                    action: () => runSearch(`from:${senderAddr}`),
                });
            }
            items.push(
                { label: "", action: () => {}, separator: true },
                { label: selectedText ? "Translate selection" : "Translate message",
                  action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) },
                { label: "", action: () => {}, separator: true },
                { label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) },
                { label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) },
                { label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) },
            );
            showContextMenu(x, y, items);
        });
    };
    if (iframe.contentDocument?.readyState === "complete") attach();
    else iframe.addEventListener("load", attach, { once: true });
}

export function clearViewer(): void {
    currentMessage = null;
    currentAccountId = "";
    lastEnvelope = null;
    showMessageGeneration++;
    const headerEl = document.getElementById("mv-header") as HTMLElement;
    const bodyEl = document.getElementById("mv-body") as HTMLElement;
    const attEl = document.getElementById("mv-attachments") as HTMLElement;
    if (bodyEl) bodyEl.innerHTML = `<div class="mv-empty">Select a message to read</div>`;
    if (headerEl) headerEl.hidden = true;
    if (attEl) attEl.hidden = true;
}

/** Render the message identified by (accountId, uid, folderId). The
 *  list passes the row's existing envelope as `envelope` so the header +
 *  preview snippet paint *immediately*, before the body fetch returns —
 *  that's the "no Fetching… empty pane" guarantee. On retry, we reuse
 *  the most recently provided envelope. */
export async function showMessage(accountId: string, uid: number, folderId?: number, specialUse?: string, isRetry = false, envelope?: ListMessage): Promise<void> {
    const gen = ++showMessageGeneration;
    if (!isRetry) retryCount = 0;
    const headerEl = document.getElementById("mv-header") as HTMLElement;
    const bodyEl = document.getElementById("mv-body") as HTMLElement;
    const attEl = document.getElementById("mv-attachments") as HTMLElement;

    // Envelope-first render: the row the user just clicked already has the
    // subject / from / to / cc / date / preview in the message-state. Use
    // that to populate the header + a snippet placeholder IMMEDIATELY so
    // tapping a message never shows just "Fetching message body..." with
    // nothing actionable. The full getMessage() call (which might block on
    // a slow IMAP body fetch) only fills in the body and attachments.
    const cached = (envelope && envelope.uid === uid && (envelope.accountId || accountId) === accountId)
        ? envelope
        : (lastEnvelope && lastEnvelope.uid === uid && (lastEnvelope.accountId || accountId) === accountId ? lastEnvelope : null);
    if (envelope) lastEnvelope = envelope;
    if (cached) {
        try { renderHeaderFromEnvelope(headerEl, cached); } catch { /* */ }
        bodyEl.innerHTML = `<div class="mv-empty">Fetching message body…<br><br><span style="color:var(--color-text-muted);font-size:0.9em">${escapeHtml(cached.preview || "")}</span></div>`;
    } else {
        bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
        headerEl.hidden = true;
    }
    attEl.hidden = true;

    try {
        const msg = await getMessage(accountId, uid, false, folderId);
        // Stale response — a newer showMessage was called while we were fetching
        if (gen !== showMessageGeneration) return;
        currentMessage = msg;
        currentAccountId = accountId;

        // Mark as read — gated by user prefs:
        //   - mailx-automark-read (default "true"): if "false", never auto-mark
        //   - mailx-automark-delay (default "2"): seconds to wait before
        //     marking. Lets the user click through messages quickly without
        //     marking ones they didn't actually read. The timer is tied to
        //     showMessageGeneration; navigating to another message advances
        //     the generation and cancels the pending mark.
        if (!msg.flags.includes("\\Seen")) {
            let enabled = true;
            let delaySec = 2;
            try {
                enabled = localStorage.getItem("mailx-automark-read") !== "false";
                const d = parseFloat(localStorage.getItem("mailx-automark-delay") || "2");
                if (Number.isFinite(d) && d >= 0) delaySec = d;
            } catch { /* private mode — defaults */ }
            if (enabled) {
                const captureGen = gen;
                const newFlags = [...msg.flags, "\\Seen"];
                if (delaySec === 0) {
                    updateFlags(accountId, uid, newFlags);
                } else {
                    setTimeout(() => {
                        // Stale: user moved on before the timer fired.
                        if (captureGen !== showMessageGeneration) return;
                        updateFlags(accountId, uid, newFlags);
                        // Reflect locally so the list row stops looking unread.
                        msg.flags = newFlags;
                        try { stateUpdateFlags(accountId, uid, newFlags); } catch { /* */ }
                    }, delaySec * 1000);
                }
            }
        }

        // Header
        headerEl.hidden = false;
        const fromEl = headerEl.querySelector(".mv-from")!;
        const toEl = headerEl.querySelector(".mv-to")!;
        fromEl.textContent = formatAddr(msg.from);
        let toLine = `To: ${msg.to.map(formatAddr).join(", ")}`;
        if (msg.cc?.length) toLine += `  Cc: ${msg.cc.map(formatAddr).join(", ")}`;
        // Always-visible Delivered-To line — shown when present and not already
        // covered by the To/Cc list. Critical for accounts with multiple aliases
        // where you need to see which one received the message at a glance.
        const toAddrs = (msg.to || []).map((a: { address: string }) => a.address.toLowerCase());
        const ccAddrs = (msg.cc || []).map((a: { address: string }) => a.address.toLowerCase());
        const dt = (msg.deliveredTo || "").toLowerCase();
        if (msg.deliveredTo && !toAddrs.includes(dt) && !ccAddrs.includes(dt)) {
            toLine += `  Delivered-To: ${msg.deliveredTo}`;
        }
        toEl.textContent = toLine;
        headerEl.querySelector(".mv-subject")!.textContent = msg.subject;
        document.dispatchEvent(new CustomEvent("mailx-message-shown", { detail: { accountId } }));

        // Right-click on email addresses in header: copy name, copy address,
        // copy both, add to contacts, plus reply actions for the whole message.
        for (const el of [fromEl, toEl]) {
            el.addEventListener("contextmenu", (e: Event) => {
                e.preventDefault();
                const me = e as MouseEvent;
                const items: MenuItem[] = [];
                const addrs = el === fromEl ? [msg.from] : [...(msg.to || []), ...(msg.cc || [])];
                for (const addr of addrs) {
                    if (!addr?.address) continue;
                    const name = addr.name || "";
                    const both = name ? `${name} <${addr.address}>` : addr.address;
                    if (name) {
                        items.push({ label: `Copy name: ${name}`, action: () => navigator.clipboard.writeText(name) });
                    }
                    items.push({ label: `Copy address: ${addr.address}`, action: () => navigator.clipboard.writeText(addr.address) });
                    if (name) {
                        items.push({ label: `Copy both: ${both}`, action: () => navigator.clipboard.writeText(both) });
                    }
                    items.push({
                        label: `Add to contacts: ${addr.address}`,
                        action: async () => {
                            await showAddContactDialog(name, addr.address);
                        },
                    });
                    // "Add to preferred" — separate path: writes to
                    // contacts.jsonc#preferred[] with an optional source tag.
                    // Distinct from "Add to contacts" which goes into the DB +
                    // pushes to Google. Preferred entries rank higher in
                    // autocomplete and survive Google sync's churn.
                    items.push({
                        label: `Add to preferred: ${addr.address}`,
                        action: async () => {
                            const tag = prompt("Tag (e.g. work, family, vendor) — leave blank for default:", "");
                            if (tag === null) return; // user cancelled
                            try {
                                await addPreferredContact({ name, email: addr.address, source: tag.trim() || undefined });
                                const status = document.getElementById("status-sync");
                                if (status) status.textContent = `Added to preferred: ${addr.address}${tag ? ` [${tag}]` : ""}`;
                            } catch (e: any) {
                                alert(`Couldn't add to preferred: ${e?.message || e}`);
                            }
                        },
                    });
                    items.push({ label: "", action: () => {}, separator: true });
                }
                items.push({ label: "Reply", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })) });
                items.push({ label: "Reply All", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })) });
                items.push({ label: "Forward", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })) });
                showContextMenu(me.clientX, me.clientY, items);
            });
        }
        headerEl.querySelector(".mv-date")!.textContent = new Date(msg.date).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });

        // Unsubscribe button (upper right of header).
        //   - One-Click (RFC 8058): POST via service; show result in status bar.
        //   - Plain HTTPS URL: open externally for user confirmation.
        //   - mailto: open a pre-filled compose so the reply uses the right account.
        const unsubBtn = document.getElementById("mv-unsubscribe") as HTMLAnchorElement;
        const httpUrl = (msg as any).listUnsubscribeHttp || "";
        const mailUrl = (msg as any).listUnsubscribeMail || "";
        const oneClick = !!(msg as any).listUnsubscribeOneClick;
        const anyUrl = httpUrl || mailUrl || msg.listUnsubscribe || "";
        if (unsubBtn) {
            if (anyUrl) {
                unsubBtn.hidden = false;
                unsubBtn.textContent = "Unsubscribe";
                unsubBtn.removeAttribute("title");
                unsubBtn.href = httpUrl || mailUrl || "#";
                unsubBtn.onclick = async (e) => {
                    e.preventDefault();
                    const status = document.getElementById("status-sync");
                    if (httpUrl && oneClick) {
                        if (status) status.textContent = "Unsubscribing…";
                        try {
                            const result = await unsubscribeOneClick(httpUrl);
                            if (status) {
                                status.textContent = result.ok
                                    ? `Unsubscribed (${result.status} ${result.statusText})`
                                    : `Unsubscribe failed: ${result.status} ${result.statusText}`;
                            }
                        } catch (err: any) {
                            if (status) status.textContent = `Unsubscribe error: ${err?.message || err}`;
                        }
                        return;
                    }
                    if (httpUrl) {
                        const api = (window as any).mailxapi;
                        if (api?.openExternal) api.openExternal(httpUrl);
                        else window.open(httpUrl, "_blank", "noopener,noreferrer");
                        return;
                    }
                    if (mailUrl) {
                        const m = mailUrl.match(/^mailto:([^?]*)(?:\?(.*))?$/i);
                        const to = m?.[1] ? decodeURIComponent(m[1]) : "";
                        const qs = new URLSearchParams(m?.[2] || "");
                        const subject = qs.get("subject") || "Unsubscribe";
                        const body = qs.get("body") || "";
                        const init = {
                            mode: "new",
                            accountId: currentAccountId,
                            to: to ? [{ name: "", address: to }] : [] as { name: string; address: string }[],
                            cc: [] as { name: string; address: string }[],
                            subject,
                            bodyHtml: body ? `<p>${body}</p>` : "",
                            inReplyTo: "",
                            references: [] as string[],
                            accounts: [] as { id: string; name: string; email: string }[],
                        };
                        sessionStorage.setItem("composeInit", JSON.stringify(init));
                        document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "new" } }));
                    }
                };
            } else {
                unsubBtn.hidden = true;
            }
        }

        // View Thread button — opens the thread popup from the message list
        // so the user can see all messages in the conversation. Works from
        // the viewer even when thread-grouping is off.
        const threadBtn = document.getElementById("mv-view-thread") as HTMLButtonElement;
        if (threadBtn) {
            const tid = (msg as any).threadId || "";
            if (tid) {
                threadBtn.hidden = false;
                threadBtn.onclick = async () => {
                    const { showThreadPopup } = await import("./message-list.js");
                    await showThreadPopup(threadBtn, { accountId, threadId: tid });
                };
            } else {
                threadBtn.hidden = true;
            }
        }

        // View Source button — shows .eml file path
        const srcBtn = document.getElementById("mv-view-source") as HTMLButtonElement;
        if (srcBtn) {
            if (msg.emlPath) {
                srcBtn.hidden = false;
                srcBtn.title = msg.emlPath;
                srcBtn.onclick = () => {
                    // Copy path to clipboard and show in status bar
                    navigator.clipboard.writeText(msg.emlPath).then(() => {
                        const status = document.getElementById("status-sync");
                        if (status) status.textContent = `Path copied: ${msg.emlPath}`;
                    }).catch(() => {
                        prompt("EML file path:", msg.emlPath);
                    });
                };
            } else {
                srcBtn.hidden = true;
            }
        }

        // Edit Draft / Send from Outbox button
        const editBtn = document.getElementById("mv-edit-draft") as HTMLButtonElement;
        if (editBtn) {
            const isDraft = specialUse === "drafts" || specialUse === "outbox";
            if (isDraft) {
                editBtn.hidden = false;
                editBtn.textContent = specialUse === "outbox" ? "Edit & Send" : "Edit Draft";
                editBtn.onclick = () => {
                    // Open compose window pre-filled with this draft
                    const init = {
                        mode: "draft",
                        accountId,
                        to: msg.to || [],
                        cc: msg.cc || [],
                        subject: msg.subject || "",
                        bodyHtml: msg.bodyHtml || "",
                        inReplyTo: msg.inReplyTo || "",
                        references: msg.references || [],
                        accounts: [] as { id: string; name: string; email: string }[],
                        draftUid: msg.uid,
                        draftFolderId: msg.folderId,
                    };
                    sessionStorage.setItem("composeInit", JSON.stringify(init));
                    // Trigger compose overlay via custom event (app.ts handles it)
                    document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "draft" } }));
                };
            } else {
                editBtn.hidden = true;
            }
        }

        // Details toggle — show extra headers (Delivered-To, Return-Path, Message-ID, etc.)
        const detailsEl = document.getElementById("mv-details") as HTMLElement;
        const detailsBtn = document.getElementById("mv-toggle-details") as HTMLButtonElement;
        if (detailsEl && detailsBtn) {
            // Q56: every row gets a Copy button so paths / IDs can be pasted
            // into accounts.jsonc hints or bug reports.
            const row = (label: string, value: string) =>
                `<div class="mv-details-row"><span class="mv-details-label">${label}:</span> <span class="mv-details-value">${escapeText(value)}</span> <button type="button" class="mv-details-copy" data-copy="${escapeText(value).replace(/"/g, "&quot;")}" title="Copy">⧉</button></div>`;
            const lines: string[] = [];
            if (msg.deliveredTo) lines.push(row("Delivered-To", msg.deliveredTo));
            if (msg.returnPath) lines.push(row("Return-Path", msg.returnPath));
            if (msg.messageId) lines.push(row("Message-ID", msg.messageId));
            if (msg.listUnsubscribe) lines.push(row("Unsubscribe", msg.listUnsubscribe));
            if (msg.emlPath) lines.push(row("EML file", msg.emlPath));
            lines.push(row("Account", accountId));
            lines.push(row("UID", `${msg.uid} (folder ${msg.folderId})`));
            detailsEl.innerHTML = lines.join("");
            detailsEl.hidden = true;
            detailsBtn.textContent = "Details";
            detailsBtn.onclick = () => {
                detailsEl.hidden = !detailsEl.hidden;
                detailsBtn.textContent = detailsEl.hidden ? "Details" : "\u2713 Details";
            };
            // Wire copy buttons.
            detailsEl.querySelectorAll<HTMLButtonElement>(".mv-details-copy").forEach(btn => {
                btn.addEventListener("click", async (e) => {
                    e.stopPropagation();
                    const val = btn.dataset.copy || "";
                    try {
                        await navigator.clipboard.writeText(val);
                        btn.textContent = "✓";
                        setTimeout(() => { btn.textContent = "⧉"; }, 1500);
                    } catch {
                        prompt("Copy:", val);
                    }
                });
            });
        }

        // Remote content banner (collapsible dropdown with sender/recipient details)
        bodyEl.innerHTML = "";
        if (msg.hasRemoteContent) {
            const senderAddr = msg.from?.address || "";
            const senderName = msg.from?.name || "";
            const senderDomain = senderAddr.split("@")[1] || "";
            const deliveredTo = msg.deliveredTo || "";
            const toAddr = msg.to?.[0]?.address || "";
            const returnPath = msg.returnPath || "";
            const isFlagged = !!(msg as any).isFlagged;
            const reputation = (msg as any).reputation as {
                flagged: boolean; listedCount: number; checkedCount: number;
                sources: Array<{ service: string; verdict: string }>;
                verdict: string; service: string;
            } | null;
            const reputationFlagged = !!(reputation && reputation.flagged);
            const reputationText = reputationFlagged
                ? `⚠ ${reputation!.listedCount} of ${reputation!.checkedCount} reputation services flag <strong>${escapeText(senderDomain)}</strong> as <strong>${escapeText(reputation!.verdict)}</strong> (${escapeText(reputation!.sources.map(s => s.service).join(", "))})`
                : "";

            const banner = document.createElement("div");
            banner.className = "mv-remote-banner"
                + (isFlagged ? " mv-remote-banner-flagged" : "")
                + (reputationFlagged ? " mv-remote-banner-reputation" : "");
            banner.innerHTML =
                (isFlagged
                    ? `<div class="mv-rb-flagged">⚠ FLAGGED: this sender or domain is on your flagged list</div>`
                    : "") +
                (reputationFlagged
                    ? `<div class="mv-rb-reputation">${reputationText}</div>`
                    : "") +
                `<div class="mv-rb-summary">` +
                    `<span class="mv-rb-toggle">&#x25B8;</span>` +
                    `<span>Remote content blocked</span>` +
                    `<span class="mv-rb-buttons">` +
                        `<button id="btn-load-remote">Load once</button>` +
                        `<button id="btn-allow-sender" title="${escapeText(senderAddr)}">Always: ${escapeText(senderAddr)}</button>` +
                        (senderDomain ? `<button id="btn-allow-domain" title="*@${escapeText(senderDomain)}">Always: *@${escapeText(senderDomain)}</button>` : "") +
                    `</span>` +
                `</div>` +
                `<div class="mv-rb-details" hidden>` +
                    `<div class="mv-rb-info">` +
                        `<div><span class="mv-rb-label">From:</span> ${escapeText(senderName ? `${senderName} <${senderAddr}>` : senderAddr)}</div>` +
                        (deliveredTo ? `<div><span class="mv-rb-label">Delivered-To:</span> ${escapeText(deliveredTo)}</div>` : "") +
                        (toAddr && toAddr !== deliveredTo ? `<div><span class="mv-rb-label">To:</span> ${escapeText(toAddr)}</div>` : "") +
                        (returnPath && returnPath !== senderAddr ? `<div><span class="mv-rb-label">Return-Path:</span> ${escapeText(returnPath)}</div>` : "") +
                    `</div>` +
                    (deliveredTo || toAddr ? `<div class="mv-rb-actions"><button id="btn-allow-to">Always allow to: ${escapeText(deliveredTo || toAddr)}</button></div>` : "") +
                    `<div class="mv-rb-actions">` +
                        `<button id="btn-flag-sender" class="mv-rb-flag-btn" title="${escapeText(senderAddr)}">${isFlagged ? "Unflag" : "Flag"} sender</button>` +
                        (senderDomain ? `<button id="btn-flag-domain" class="mv-rb-flag-btn" title="*@${escapeText(senderDomain)}">Flag domain *@${escapeText(senderDomain)}</button>` : "") +
                    `</div>` +
                    `<div class="mv-rb-actions"><button id="btn-edit-allowlist" title="View / edit the full allowlist">Edit allowlist…</button></div>` +
                `</div>`;
            bodyEl.appendChild(banner);

            // Toggle dropdown — click arrow or text to expand details
            const summary = banner.querySelector(".mv-rb-summary")!;
            const details = banner.querySelector(".mv-rb-details") as HTMLElement;
            const toggle = banner.querySelector(".mv-rb-toggle")!;
            summary.addEventListener("click", (e) => {
                if ((e.target as HTMLElement).tagName === "BUTTON") return;
                details.hidden = !details.hidden;
                toggle.textContent = details.hidden ? "\u25B8" : "\u25BE";
            });

            const loadRemote = async () => {
                banner.remove();
                const full = await getMessage(accountId, uid, true);
                if (full.bodyHtml) {
                    const oldIframe = bodyEl.querySelector("iframe");
                    if (oldIframe) oldIframe.remove();
                    const iframe = document.createElement("iframe");
                    iframe.srcdoc = wrapHtmlBody(full.bodyHtml, true);
                    bodyEl.appendChild(iframe);
                    installPreviewControls(iframe);
                }
            };

            banner.querySelector("#btn-load-remote")!.addEventListener("click", loadRemote);

            banner.querySelector("#btn-allow-sender")?.addEventListener("click", async () => {
                await allowRemoteContent("sender", senderAddr);
                loadRemote();
            });

            banner.querySelector("#btn-allow-domain")?.addEventListener("click", async () => {
                await allowRemoteContent("domain", senderDomain);
                loadRemote();
            });

            banner.querySelector("#btn-allow-to")?.addEventListener("click", async () => {
                const addr = deliveredTo || toAddr;
                if (!addr) return;
                await allowRemoteContent("recipient", addr);
                loadRemote();
            });

            // Flag (or unflag) sender / domain — toggles the allowlist's
            // flaggedSenders / flaggedDomains lists. Subsequent messages
            // from this sender or domain show a red FLAGGED warning at the
            // top of this banner. Doesn't load remote content; this is a
            // signal-to-the-user feature, orthogonal to allow/block.
            const onFlagToggle = async (type: "sender" | "domain", value: string) => {
                if (!value) return;
                try {
                    const result = await flagSenderOrDomain(type, value);
                    const status = document.getElementById("status-sync");
                    if (status) status.textContent = result.flagged
                        ? `Flagged ${type}: ${value}`
                        : `Unflagged ${type}: ${value}`;
                    // Re-render this message so the banner picks up the new
                    // flagged state without the user having to reselect.
                    if (currentMessage) {
                        showMessage(currentAccountId, currentMessage.uid, currentMessage.folderId, specialUse).catch(() => { /* */ });
                    }
                } catch (e: any) {
                    const status = document.getElementById("status-sync");
                    if (status) status.textContent = `Flag failed: ${e?.message || e}`;
                }
            };
            banner.querySelector("#btn-flag-sender")?.addEventListener("click", () => onFlagToggle("sender", senderAddr));
            banner.querySelector("#btn-flag-domain")?.addEventListener("click", () => onFlagToggle("domain", senderDomain));

            // "Edit allowlist…" — fires a document-level event that app.ts
            // listens for and opens the JSONC editor pre-selected to
            // allowlist.jsonc. Keeps message-viewer free of the editor import.
            banner.querySelector("#btn-edit-allowlist")?.addEventListener("click", () => {
                document.dispatchEvent(new CustomEvent("mailx-open-jsonc-editor", { detail: { file: "allowlist.jsonc" } }));
            });

        }

        // Body fetch error — show banner above (empty) body instead of polluting
        // the main content area with the error text. Transient errors get a retry.
        if ((msg as any).bodyError) {
            const err = String((msg as any).bodyError);
            const isTransient = !!(msg as any).bodyErrorTransient;
            const errBanner = document.createElement("div");
            errBanner.className = "mv-system-message mv-system-error";
            errBanner.innerHTML = `
                <div class="mv-system-tag">mailx</div>
                <div class="mv-system-title">Body unavailable</div>
                <div class="mv-system-body">${err.replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c] || c))}</div>
                ${isTransient ? `<div class="mv-system-actions"><button id="btn-retry-body" class="mv-system-btn">Retry</button></div>` : ""}
            `;
            bodyEl.appendChild(errBanner);
            if (isTransient) {
                errBanner.querySelector("#btn-retry-body")?.addEventListener("click", async () => {
                    errBanner.remove();
                    bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
                    try {
                        const retry: any = await getMessage(accountId, uid, false);
                        if (retry.bodyError) {
                            // Still failing — rebuild the error banner via recursive render.
                            showMessage(accountId, uid, folderId, specialUse).catch(() => { });
                            return;
                        }
                        bodyEl.innerHTML = "";
                        if (retry.bodyHtml) {
                            const iframe = document.createElement("iframe");
                            iframe.sandbox.add("allow-same-origin");
                            iframe.sandbox.add("allow-popups");
                            iframe.sandbox.add("allow-popups-to-escape-sandbox");
                            iframe.sandbox.add("allow-top-navigation-by-user-activation");
                            iframe.sandbox.add("allow-scripts");
                            iframe.srcdoc = wrapHtmlBody(retry.bodyHtml, retry.remoteAllowed);
                            bodyEl.appendChild(iframe);
                            installPreviewControls(iframe);
                        } else if (retry.bodyText) {
                            const pre = document.createElement("pre");
                            pre.style.cssText = "padding: 1rem; white-space: pre-wrap; word-break: break-word;";
                            pre.innerHTML = linkifyText(retry.bodyText);
                            bodyEl.appendChild(pre);
                        } else {
                            bodyEl.innerHTML = `<div class="mv-empty">No content</div>`;
                        }
                    } catch (e: any) {
                        bodyEl.innerHTML = `<div class="mv-empty">Retry failed: ${e.message}</div>`;
                    }
                });
            }
            return;
        }

        // Body in sandboxed iframe
        if (msg.bodyHtml) {
            const iframe = document.createElement("iframe");
            iframe.sandbox.add("allow-same-origin");
            iframe.sandbox.add("allow-popups");
            iframe.sandbox.add("allow-popups-to-escape-sandbox");
            iframe.sandbox.add("allow-top-navigation-by-user-activation");
            // allow-scripts lets OUR injected <script> run (for Android link
            // interception — parent-side contentDocument listeners don't fire
            // reliably on Android WebView). CSP with a nonce restricts script
            // execution to our tag only; inline scripts in the email body are
            // still blocked.
            iframe.sandbox.add("allow-scripts");
            iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
            bodyEl.appendChild(iframe);
            installPreviewControls(iframe);
        } else if (msg.bodyText) {
            const pre = document.createElement("pre");
            pre.style.cssText = "padding: 1rem; white-space: pre-wrap; word-break: break-word;";
            // Auto-linkify URLs in plain text
            pre.innerHTML = linkifyText(msg.bodyText);
            bodyEl.appendChild(pre);
        } else {
            bodyEl.innerHTML = `<div class="mv-empty">No content</div>`;
        }

        // Attachments — always clear first to avoid stale chips from previous message
        attEl.innerHTML = "";
        attEl.hidden = true;
        if (msg.attachments?.length) {
            attEl.hidden = false;
            for (let i = 0; i < msg.attachments.length; i++) {
                const att = msg.attachments[i];
                const chip = document.createElement("a");
                chip.className = "mv-att-chip";
                chip.textContent = `\uD83D\uDCCE ${att.filename} (${formatSize(att.size)})`;
                chip.href = "#";
                chip.title = `${att.filename} (${att.mimeType})`;
                chip.addEventListener("click", async (e) => {
                    e.preventDefault();
                    try {
                        const data = await getAttachment(accountId, uid, i, msg.folderId);
                        const bridge = (window as any)._nativeBridge;
                        if (bridge?.openAttachment) {
                            // Android: blob URLs don't work in WebView. Pass base64
                            // to native bridge which saves to Downloads and opens
                            // with the system viewer.
                            await bridge.openAttachment(att.filename, data.contentType, data.content);
                        } else {
                            const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
                            const blob = new Blob([bytes], { type: data.contentType });
                            const url = URL.createObjectURL(blob);
                            window.open(url, "_blank");
                        }
                    } catch (err: any) {
                        console.error(`Attachment download failed: ${err.message}`);
                    }
                });
                // Drag the chip to an external target (Explorer / Finder / Files app)
                // to drop the file there. Uses the Chromium `DownloadURL` dataTransfer
                // format: "mime:filename:blob-url". We fetch the attachment first so
                // the blob URL is valid by the time the drop lands.
                chip.draggable = true;
                chip.addEventListener("dragstart", async (e) => {
                    if (!e.dataTransfer) return;
                    try {
                        const data = await getAttachment(accountId, uid, i, msg.folderId);
                        const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
                        const blob = new Blob([bytes], { type: data.contentType || "application/octet-stream" });
                        const url = URL.createObjectURL(blob);
                        // Sanitize filename: no path separators, no newlines.
                        const safeName = (att.filename || "attachment").replace(/[\r\n"\/\\]/g, "_");
                        const downloadUrl = `${data.contentType || "application/octet-stream"}:${safeName}:${url}`;
                        e.dataTransfer.setData("DownloadURL", downloadUrl);
                        e.dataTransfer.effectAllowed = "copy";
                    } catch (err: any) {
                        console.error(`Attachment drag-out failed: ${err.message || err}`);
                    }
                });
                attEl.appendChild(chip);
            }
        }
    } catch (e: any) {
        const err = e.message || "Unknown error";
        console.error("showMessage error:", e);
        // "Message was deleted from the server" — the service already dropped
        // the local row. Remove it from the list so the UI advances to the next
        // message instead of sitting on a stale error banner.
        const isNotFound = /deleted from the server|isNotFound|not found|Not Found|404/.test(err);
        if (isNotFound) {
            // Drop the stale row so the list auto-advances to the next message
            // (or clears the viewer). Leaves the user a way back on mobile where
            // the viewer takes the whole screen. The list owns focus handoff —
            // we surface the removal as an event and let it run its own
            // removeMessages flow (which both filters the list and re-focuses
            // a survivor or clears this pane).
            document.dispatchEvent(new CustomEvent("mailx-remove-stale", {
                detail: { accountId, uid },
            }));
            return;
        }
        if (retryCount < 3) {
            retryCount++;
            bodyEl.innerHTML = `<div class="mv-empty">Loading failed: ${err} — retrying (${retryCount}/3)...</div>`;
            setTimeout(() => { if (gen === showMessageGeneration) showMessage(accountId, uid, folderId, specialUse, true); }, 3000);
        } else {
            bodyEl.innerHTML = `<div class="mv-empty">Failed to load: ${err}</div>`;
        }
    }
}

function formatAddr(addr: { name: string; address: string }): string {
    if (addr.name) return `${addr.name} <${addr.address}>`;
    return addr.address;
}

/** Render the viewer header from a list-row envelope (instant — no body
 *  fetch awaited). Used to populate the header pane the moment a message
 *  is clicked so the user always sees something actionable; getMessage()
 *  later overwrites the same fields with the authoritative values from the
 *  body parse (which can add Cc, Delivered-To, etc. that the list row
 *  doesn't track). */
function renderHeaderFromEnvelope(headerEl: HTMLElement, env: any): void {
    headerEl.hidden = false;
    const fromEl = headerEl.querySelector(".mv-from");
    const toEl = headerEl.querySelector(".mv-to");
    const subjEl = headerEl.querySelector(".mv-subject");
    const dateEl = headerEl.querySelector(".mv-date");
    if (fromEl) fromEl.textContent = formatAddr(env.from);
    if (toEl) {
        let toLine = `To: ${(env.to || []).map(formatAddr).join(", ")}`;
        if (env.cc?.length) toLine += `  Cc: ${env.cc.map(formatAddr).join(", ")}`;
        toEl.textContent = toLine;
    }
    if (subjEl) subjEl.textContent = env.subject || "";
    if (dateEl) {
        try { dateEl.textContent = new Date(env.date).toLocaleString(); }
        catch { dateEl.textContent = ""; }
    }
}

function escapeHtml(s: string): string {
    return (s || "").replace(/[&<>"']/g, c =>
        ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]!));
}

/** Convert plain text URLs into clickable links, escaping HTML */
function linkifyText(text: string): string {
    // Escape HTML first
    const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
    // Then linkify URLs
    return escaped.replace(
        /(https?:\/\/[^\s<>"')\]]+)/g,
        '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
    );
}

function escapeText(s: string): string {
    const div = document.createElement("div");
    div.textContent = s;
    return div.innerHTML;
}

/** Minimal add-contact modal: name + email + organization with a duplicate
 *  check (checks the contacts DB for an existing row with the same email
 *  and surfaces it so the user can update instead of creating a second
 *  row with a different name). Future: AI-extracted fields from the letter
 *  body populate the form before it opens. */
async function showAddContactDialog(nameIn: string, emailIn: string): Promise<void> {
    let dup: { name: string; email: string; source: string } | null = null;
    try {
        const existing = await listContacts(emailIn, 1, 10);
        const match = (existing?.items || []).find((c: any) => (c.email || "").toLowerCase() === emailIn.toLowerCase());
        if (match) dup = match;
    } catch { /* non-fatal — dialog still works without dup info */ }

    const backdrop = document.createElement("div");
    backdrop.className = "mailx-modal-backdrop";
    const panel = document.createElement("div");
    panel.className = "mailx-modal";
    panel.innerHTML = `
        <div class="mailx-modal-title">
            <span class="mailx-modal-title-text">${dup ? "Update contact" : "Add contact"}</span>
            <button type="button" class="mailx-modal-close" id="ac-close" aria-label="Close">&times;</button>
        </div>
        ${dup ? `<div class="mailx-modal-info">Already in address book as <strong>${escapeText(dup.name || "(no name)")}</strong> (${escapeText(dup.source)}). Saving will update the name.</div>` : ""}
        <label class="mailx-modal-label">Name
            <input class="mailx-modal-input" id="ac-name" type="text" value="${escapeText(dup?.name || nameIn || "")}" autofocus>
        </label>
        <label class="mailx-modal-label">Email
            <input class="mailx-modal-input" id="ac-email" type="email" value="${escapeText(emailIn)}" readonly>
        </label>
        <label class="mailx-modal-label">Organization <span style="color:var(--color-text-muted);font-size:0.85em">(optional)</span>
            <input class="mailx-modal-input" id="ac-org" type="text" placeholder="">
        </label>
        <div class="mailx-modal-buttons">
            <span class="mailx-modal-spacer"></span>
            <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
            <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">${dup ? "Update" : "Save"}</button>
        </div>`;
    backdrop.appendChild(panel);
    document.body.appendChild(backdrop);

    const close = () => backdrop.remove();
    panel.querySelector<HTMLButtonElement>("#ac-close")!.addEventListener("click", close);
    panel.querySelectorAll<HTMLButtonElement>(".mailx-modal-btn").forEach(btn => {
        btn.addEventListener("click", async () => {
            if (btn.dataset.action === "cancel") { close(); return; }
            const nameEl = panel.querySelector<HTMLInputElement>("#ac-name")!;
            const emailEl = panel.querySelector<HTMLInputElement>("#ac-email")!;
            btn.disabled = true;
            btn.textContent = "Saving…";
            try {
                // upsertContact is the two-way cache path (enqueues a Google
                // People push); for pure local-first addContact would also
                // work but skips the Google sync. Use upsertContact so the
                // row propagates to Google Contacts next drain tick.
                await upsertContact(nameEl.value.trim(), emailEl.value.trim());
                close();
            } catch (e: any) {
                btn.disabled = false;
                btn.textContent = dup ? "Update" : "Save";
                alert(`Couldn't save: ${e?.message || e}`);
            }
        });
    });
    const onKey = (e: KeyboardEvent) => {
        if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey, true); }
    };
    document.addEventListener("keydown", onKey, true);
    // addContact is kept as a legacy silent path (no-form) for any caller
    // that still invokes it — currently none after this refactor.
    void addContact;
}

function formatSize(bytes: number): string {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)} KB`;
    return `${(bytes / 1048576).toFixed(1)} MB`;
}

function wrapHtmlBody(html: string, allowRemote = false): string {
    // CSP blocks remote resources (tracking pixels, external CSS). Inline
    // scripts are allowed via 'unsafe-inline' so our injected link-tap handler
    // runs; email-body <script> tags and on* handlers are stripped server-side
    // by sanitizeHtml() in mailx-core, so this doesn't actually widen the
    // attack surface. (A per-render nonce would be tidier, but meta-CSP with
    // nonces isn't reliably honored across older WebViews — and when a nonce
    // is present, 'unsafe-inline' is ignored, so our script fell back to
    // blocked on those WebViews.)
    const csp = allowRemote
        ? ""
        : `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: cid:; form-action 'none';">`;
    return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
${csp}
<style>
    html, body { touch-action: pan-y pinch-zoom; }
    html { height: 100%; overflow-y: auto; overflow-x: hidden; }
    body {
        font-family: system-ui, sans-serif;
        font-size: 17.5px;
        line-height: 1.5;
        color: #1a1a2e;
        background: #fff;
        padding: 1rem;
        margin: 0;
        min-height: 100%;
        word-break: break-word;
        color-scheme: dark light;
    }
    img { max-width: 100%; height: auto; }
    a { color: #1a6dd4; }
    pre, code { white-space: pre-wrap; }
    blockquote { border-left: 3px solid #ccc; padding-left: 1rem; margin-left: 0; color: #666; }
    @media (prefers-color-scheme: dark) {
        body { color: #cdd6f4; background: #282840; }
        a { color: #89b4fa; }
        blockquote { border-color: #45475a; color: #6c7086; }
    }
</style>
<base target="_blank">
<script>
// Link interception — Android WebView doesn't fire the default <a target="_blank">
// new-window handler, so we postMessage to the parent which routes through the
// native bridge (mailxapi://openurl) to Launcher.OpenAsync.
(function () {
    function handleLinkTap(e) {
        var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
        if (!a) return;
        var url = a.href;
        if (!url || url.indexOf("javascript:") === 0 || url.charAt(0) === "#") return;
        e.preventDefault();
        e.stopPropagation();
        window.parent.postMessage({ type: "linkClick", url: url }, "*");
    }
    document.addEventListener("click", handleLinkTap, true);
    // Android WebView fallback: some builds drop the synthetic click after
    // touchend, so treat a stationary touchstart→touchend on the same link
    // as a tap. Anything that moves more than TAP_SLOP pixels is a scroll
    // and must NOT activate the link.
    var TAP_SLOP = 10;
    var lastTouchTarget = null;
    var lastTouchX = 0;
    var lastTouchY = 0;
    var touchMoved = false;
    // All touch listeners are passive so Android WebView can compositor-scroll
    // the iframe without waiting on our JS. handleLinkTap's preventDefault only
    // matters for the "click" path (which is non-passive by default).
    document.addEventListener("touchstart", function (e) {
        var t0 = e.touches && e.touches[0];
        lastTouchX = t0 ? t0.clientX : 0;
        lastTouchY = t0 ? t0.clientY : 0;
        touchMoved = false;
        lastTouchTarget = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
    }, { passive: true, capture: true });
    document.addEventListener("touchmove", function (e) {
        if (touchMoved) return;
        var t = e.touches && e.touches[0];
        if (!t) return;
        if (Math.abs(t.clientX - lastTouchX) > TAP_SLOP || Math.abs(t.clientY - lastTouchY) > TAP_SLOP) {
            touchMoved = true;
            lastTouchTarget = null;
        }
    }, { passive: true, capture: true });
    document.addEventListener("touchend", function (e) {
        if (touchMoved) { lastTouchTarget = null; touchMoved = false; return; }
        var t = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
        if (lastTouchTarget && lastTouchTarget === t) handleLinkTap(e);
        lastTouchTarget = null;
    }, { passive: true, capture: true });
    document.addEventListener("touchcancel", function () {
        lastTouchTarget = null; touchMoved = false;
    }, { passive: true, capture: true });
    // Link hover popover removed 2026-04-24 — user feedback: it persisted
    // over the message body when the dismissers (mousedown/scroll/blur)
    // didn't fire from inside the iframe, leaving a multi-line URL hanging
    // in the middle of the reading pane. Right-click (desktop) and long-
    // press (touch) on a link still open the existing C29 menu with Open /
    // Save-as / Copy URL / Copy link-text.
    // C29: right-click on a link → ask parent for the Open/Save/Copy menu.
    document.addEventListener("contextmenu", function (e) {
        var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
        if (!a) return;  // let parent's body-context handler take over
        e.preventDefault();
        e.stopPropagation();
        var rect = (e.target.getBoundingClientRect && e.target.getBoundingClientRect()) || { left: 0, top: 0 };
        window.parent.postMessage({
            type: "linkContextMenu",
            url: a.href,
            text: (a.textContent || "").slice(0, 100),
            x: e.clientX, y: e.clientY,
            iframeLeft: rect.left, iframeTop: rect.top
        }, "*");
    });
    // Key forwarding — Delete, Ctrl+D, arrow keys, etc. need to reach app.ts
    // even when focus is inside the sandboxed iframe. Parent-side
    // contentDocument listeners (see installPreviewControls) work on
    // desktop WebView2 but not Android WebView, so we post every keydown
    // that isn't plain typing.
    document.addEventListener("keydown", function (e) {
        var t = e.target;
        if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
        // Zoom keys handled by parent-side installPreviewControls; don't double-send.
        if (e.ctrlKey && (e.key === "=" || e.key === "+" || e.key === "-" || e.key === "0")) return;
        // Preventing the iframe's default for keys we forward to the parent
        // is essential — the parent's preventDefault on the synthetic
        // keydown can't suppress the browser's reaction to the ORIGINAL
        // event (Ctrl+R reload, Ctrl+F find, etc.). Suppress here so the
        // browser doesn't act before the parent processes the action.
        var k = (e.key || "").toLowerCase();
        var isShortcut = e.ctrlKey && !e.altKey && !e.metaKey && (
            k === "r" || k === "f" || k === "n" || k === "a" || k === "d" ||
            k === "z" || k === "y" || k === "k"
        );
        if (isShortcut) e.preventDefault();
        window.parent.postMessage({
            type: "previewKey",
            key: e.key, code: e.code,
            ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
        }, "*");
    });
})();
</script>
</head><body>${html}</body></html>`;
}

/** Open the current message in a separate view: floating draggable overlay
 *  on desktop (multiple at once, like compose), full-screen mode on mobile.
 *  Threshold matches the layout.css responsive breakpoint so the experience
 *  is consistent with other narrow-mode behavior. Snapshot in time — the
 *  pop-out doesn't auto-update if the user clicks another message. */
export function popOutCurrentMessage(): void {
    if (!currentMessage) return;
    const isNarrow = window.innerWidth <= 768;
    if (isNarrow) {
        document.body.classList.toggle("viewer-fullscreen");
        return;
    }
    spawnDesktopPopout(currentMessage, currentAccountId);
}

/** Build a floating overlay carrying a snapshot of the message: header
 *  (subject, from, to, date) + sandboxed body iframe + attachment chips.
 *  Reuses the compose-overlay drag/resize/close pattern. Independent of the
 *  main viewer — opening pop-out for message A then switching the main pane
 *  to message B leaves A visible in its overlay. */
function spawnDesktopPopout(msg: any, accountId: string): void {
    const wrapper = document.createElement("div");
    wrapper.className = "compose-overlay viewer-popout";
    wrapper.style.cssText = "position:fixed;top:60px;right:20px;width:min(720px,55vw);height:min(800px,80vh);z-index:1000;border:1px solid var(--color-border, #ccc);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;background:var(--color-bg, #fff);resize:both;overflow:hidden;";

    const titleBar = document.createElement("div");
    titleBar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:var(--color-bg-alt, #e8ecf0);color:var(--color-text, #000);border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;font-size:13px;";
    const titleText = document.createElement("span");
    titleText.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-weight:600;";
    titleText.textContent = msg.subject || "(no subject)";
    titleBar.appendChild(titleText);

    const closeBtn = document.createElement("button");
    closeBtn.textContent = "✕";
    closeBtn.title = "Close pop-out";
    closeBtn.style.cssText = "background:none;border:none;font-size:16px;cursor:pointer;color:#666;padding:2px 8px;border-radius:4px;flex-shrink:0;";
    closeBtn.addEventListener("mouseenter", () => closeBtn.style.color = "#c00");
    closeBtn.addEventListener("mouseleave", () => closeBtn.style.color = "#666");
    closeBtn.addEventListener("click", () => wrapper.remove());
    titleBar.appendChild(closeBtn);

    const headerInfo = document.createElement("div");
    headerInfo.style.cssText = "padding:8px 12px;border-bottom:1px solid var(--color-border, #ddd);font-size:13px;line-height:1.4;flex-shrink:0;";
    const formatAddrLocal = (a: { name?: string; address: string }) =>
        a.name ? `${a.name} <${a.address}>` : a.address;
    const fromStr = formatAddrLocal(msg.from || { address: "" });
    const toStr = (msg.to || []).map(formatAddrLocal).join(", ");
    const ccStr = msg.cc?.length ? ` Cc: ${msg.cc.map(formatAddrLocal).join(", ")}` : "";
    const dateStr = msg.date ? new Date(msg.date).toLocaleString() : "";
    headerInfo.innerHTML =
        `<div><strong>${escapeHtmlLocal(fromStr)}</strong></div>` +
        `<div style="color:var(--color-text-muted, #666)">To: ${escapeHtmlLocal(toStr)}${escapeHtmlLocal(ccStr)}</div>` +
        `<div style="color:var(--color-text-muted, #666);font-size:12px">${escapeHtmlLocal(dateStr)}</div>`;

    const bodyContainer = document.createElement("div");
    bodyContainer.style.cssText = "flex:1;overflow:hidden;display:flex;";
    if (msg.bodyHtml) {
        const iframe = document.createElement("iframe");
        iframe.sandbox.add("allow-same-origin");
        iframe.sandbox.add("allow-popups");
        iframe.sandbox.add("allow-popups-to-escape-sandbox");
        iframe.sandbox.add("allow-top-navigation-by-user-activation");
        iframe.sandbox.add("allow-scripts");
        iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
        iframe.style.cssText = "flex:1;border:none;width:100%;background:#fff;";
        bodyContainer.appendChild(iframe);
    } else {
        const pre = document.createElement("pre");
        pre.style.cssText = "padding:12px;white-space:pre-wrap;word-break:break-word;margin:0;flex:1;overflow:auto;";
        pre.textContent = msg.bodyText || "(no content)";
        bodyContainer.appendChild(pre);
    }

    // Drag — same pattern as compose-overlay: pointer-events:none on the
    // iframe so cursor crossing into it doesn't lose drag events.
    let dragX = 0, dragY = 0;
    titleBar.addEventListener("mousedown", (e: MouseEvent) => {
        if (e.target === closeBtn) return;
        e.preventDefault();
        const rect = wrapper.getBoundingClientRect();
        dragX = e.clientX - rect.left;
        dragY = e.clientY - rect.top;
        const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
        bodyContainer.style.pointerEvents = "none";
        document.body.style.userSelect = "none";
        const onMove = (ev: MouseEvent) => {
            ev.preventDefault();
            const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);
            const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);
            wrapper.style.left = `${left}px`;
            wrapper.style.top = `${top}px`;
            wrapper.style.right = "auto";
            wrapper.style.bottom = "auto";
        };
        const onUp = () => {
            bodyContainer.style.pointerEvents = "";
            document.body.style.userSelect = "";
            document.removeEventListener("mousemove", onMove);
            document.removeEventListener("mouseup", onUp);
        };
        document.addEventListener("mousemove", onMove);
        document.addEventListener("mouseup", onUp);
    });

    // Bring to front on click — shared with compose-overlay so they all
    // restack uniformly.
    wrapper.addEventListener("mousedown", () => {
        document.querySelectorAll(".compose-overlay").forEach(el => (el as HTMLElement).style.zIndex = "1000");
        wrapper.style.zIndex = "1001";
    });

    // Cascade pop-outs so they don't all stack at the same coords.
    const existing = document.querySelectorAll(".viewer-popout").length;
    if (existing > 0) {
        wrapper.style.top = `${60 + existing * 28}px`;
        wrapper.style.right = `${20 + existing * 28}px`;
    }

    void accountId; // accountId reserved for future per-account actions on the popout
    wrapper.appendChild(titleBar);
    wrapper.appendChild(headerInfo);
    wrapper.appendChild(bodyContainer);
    document.body.appendChild(wrapper);
}

function escapeHtmlLocal(s: string): string {
    return (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
