/**
 * Live spell-check for the TinyMCE compose editor.
 *
 * Why this exists: WebView2's built-in spell checker isn't reliably
 * enabled on our msger-hosted compose iframe (see msger main.rs around
 * AreDefaultContextMenusEnabled — the comment claims it leaves
 * defaults for spell-suggest menus, but `IsSpellcheckEnabled` is never
 * explicitly set and the default varies by WebView2 runtime). Rather
 * than depend on the host, we ship a JS spellchecker (`nspell`) + the
 * standard `dictionary-en` Hunspell files.
 *
 * Two layers:
 *   1. Live decoration — words that nspell flags get wrapped in a
 *      `<span data-mailx-spellerror="1">` while editing. CSS gives them
 *      a wavy red underline (text-decoration: underline wavy #d33). The
 *      markers are stripped at serialization time so they never reach
 *      the sent message or the saved draft.
 *   2. Right-click — clicking on a marker shows our own popup with
 *      suggestions plus "Add to dictionary" / "Ignore (this session)".
 *
 * Decoration runs:
 *   - Once after the editor inits + dict loads (initial scan of pre-
 *     populated quote / signature, though we skip blockquote content).
 *   - Debounced after every input (~500 ms idle).
 *   - After setContent (paste, reply-quote insertion, …).
 *
 * The decoration walker:
 *   - Skips `<blockquote>` (the quoted reply — not the user's words),
 *     `<code>`, `<pre>`, `<a>` (URLs aren't natural-language words),
 *     and content inside any existing marker (so re-scans don't double-
 *     wrap).
 *   - Uses TinyMCE's `undoManager.ignore` + selection bookmarks so the
 *     decoration mutations don't pollute undo and don't move the caret.
 *
 * Dictionary persistence:
 *   - User additions: `localStorage["mailx-user-dict"]` (a JSON array).
 *   - "Ignore this session": in-memory only via `nspell.add()`.
 */
// @ts-expect-error — nspell ships no type defs. Treated as `any`; the
//   surface we use (`new NSpell({aff, dic})`, `.correct`, `.suggest`,
//   `.add`) is small and stable.
import NSpell from "nspell";
import { getUserDict, addUserDictWord, addUserDictWords } from "../lib/api-client.js";
// Cloud-mirrored dictionary. `userdict.csv` on GDrive (a plain one-word-
// per-line list) is the source of truth; the localStorage entry is a
// write-through cache so the popup of suggestions can resolve synchronously
// while the service round-trips the add.
const USER_DICT_KEY = "mailx-user-dict";
const MARKER_ATTR = "data-mailx-spellerror";
// Long enough that a mid-word pause doesn't fire decoration. Bob 2026-05-12:
// "popping up the redlines too quickly and then very slow about removing
// them and you keep extending them. At least wait till I finish typing a
// word." 500 ms was too eager. 1200 ms feels reactive after pause but
// stays out of the way while typing.
const DECORATE_DEBOUNCE_MS = 1200;
// Removal-only cleanup runs on a much shorter debounce than the full
// decorate pass. Removing a corrected word's red underline can't thrash
// the way *adding* underlines mid-typing can, so it doesn't need the calm
// 1200 ms — a corrected word's underline clearing in ~0.3 s instead of
// 1.2 s is the difference Bob asked about (2026-05-17).
const CLEANUP_DEBOUNCE_MS = 300;
const MIN_WORD_LEN = 3;
const SKIP_TAGS = new Set(["BLOCKQUOTE", "CODE", "PRE", "A", "SCRIPT", "STYLE", "KBD", "SAMP", "VAR"]);
let spellPromise = null;
async function getSpell() {
    if (spellPromise)
        return spellPromise;
    spellPromise = (async () => {
        const [affRes, dicRes] = await Promise.all([
            fetch("../lib/dict/en.aff"),
            fetch("../lib/dict/en.dic"),
        ]);
        if (!affRes.ok || !dicRes.ok) {
            throw new Error(`spellcheck: dict fetch failed (aff=${affRes.status} dic=${dicRes.status})`);
        }
        const [aff, dic] = await Promise.all([affRes.text(), dicRes.text()]);
        const sp = new NSpell({ aff, dic });
        // 1) Seed from local cache so the editor never has to wait on
        //    network for known-correct words to disappear from the
        //    redline pass.
        try {
            const raw = localStorage.getItem(USER_DICT_KEY);
            if (raw)
                for (const w of JSON.parse(raw))
                    sp.add(w);
        }
        catch { /* corrupt cache — start clean */ }
        // 2) Pull the cloud copy, union it in, and reconcile. Fire-and-forget
        //    — if it fails the cache still works.
        getUserDict().then(cloud => {
            const cloudArr = Array.isArray(cloud) ? cloud : [];
            for (const w of cloudArr)
                sp.add(w);
            // Read this machine's local cache.
            let local = [];
            try {
                const raw = localStorage.getItem(USER_DICT_KEY);
                local = raw ? JSON.parse(raw) : [];
            }
            catch {
                local = [];
            }
            // Reconcile up: words that exist only in localStorage (e.g. added
            // on a build where the cloud round-trip was a silent no-op) get
            // pushed to the server so they land in userdict.csv.
            const cloudSet = new Set(cloudArr);
            const localOnly = local.filter(w => !cloudSet.has(w));
            if (localOnly.length > 0) {
                addUserDictWords(localOnly).catch(e => console.error("[spell] reconcile:", e));
            }
            // Refresh the cache with the union so the next boot starts whole.
            try {
                const merged = [...new Set([...local, ...cloudArr])];
                localStorage.setItem(USER_DICT_KEY, JSON.stringify(merged));
            }
            catch { /* */ }
        }).catch(() => { });
        return sp;
    })();
    return spellPromise;
}
function addToUserDict(word, sp) {
    // Local cache: synchronous, so suggestions disappear immediately.
    try {
        const raw = localStorage.getItem(USER_DICT_KEY);
        const arr = raw ? JSON.parse(raw) : [];
        if (!arr.includes(word)) {
            arr.push(word);
            localStorage.setItem(USER_DICT_KEY, JSON.stringify(arr));
        }
    }
    catch { /* */ }
    sp.add(word);
    // Cloud: fire-and-forget so the right-click "Add" doesn't block the
    // editor. The service merges with the existing cloud copy so concurrent
    // adds from a second machine don't lose entries.
    addUserDictWord(word).catch(e => console.error("[spell] addUserDictWord:", e));
}
// ── Live decoration ───────────────────────────────────────────────
/** Walk the editor body, wrap newly-misspelled words, unwrap markers
 *  that are now correct. Mutations are wrapped in undoManager.ignore so
 *  they don't pollute undo history; selection is preserved via a
 *  TinyMCE bookmark. */
function decorate(editor, sp) {
    const body = editor.getBody?.();
    const doc = editor.getDoc?.();
    if (!body || !doc)
        return;
    // "Don't fight active typing" is enforced WORD-LEVEL by the walker's
    // caret-in-word skip below — NOT by a function-level early-return.
    // The earlier function-level bail (skip the whole pass if the caret
    // sits in any marker) had a worse failure mode: once a marker existed
    // and the caret stayed inside it, browsers extended the marker as
    // adjacent text was typed, and decorate never ran to unwrap-and-
    // re-mark, so the underline grew to span entire sentences and
    // persisted (Bob 2026-05-22 "the persistent spellcheck twiddle is
    // not going away"). The walker already skips the specific word the
    // caret is on; everything else is fair game.
    // Caret preservation: TinyMCE's getBookmark(2) + moveToBookmark
    // claimed to be mutation-safe, but in practice it landed the caret
    // at the START of the nearest wrapped span (Bob 2026-05-12: "you
    // violently yank the cursor and plop it down at the beginning of
    // the twiddle"). Replace with an absolute-character-offset save
    // before mutations and a walk-to-restore after — robust against
    // any reshape that preserves the text content (which our wrap/
    // unwrap operations do by construction).
    // Save the focusNode REFERENCE too. The abs-offset alone loses the
    // user's element when they sit in an empty `<p>` (typical reply
    // scaffold: empty <p> on top + `<div class="reply">` below). With
    // only abs=0, restore walks to the first TEXT node — which lives
    // INSIDE the quoted reply block — and caret yanks INTO the quote
    // every 1.2 s (Bob 2026-05-25: "I move the cursor to the top, wait,
    // it goes to the beginning of the reply"). The decorate mutations
    // only wrap/unwrap misspelling spans inside text — they don't touch
    // the user's `<p>` containers — so the focusNode reference survives
    // the pass unchanged. Restore by reference when we can; abs-offset
    // is the fallback when the node was inside a re-wrapped span.
    let savedFocusNode = null;
    let savedFocusOffset = 0;
    const _preSel = doc.getSelection();
    if (_preSel && _preSel.rangeCount > 0) {
        savedFocusNode = _preSel.focusNode;
        savedFocusOffset = _preSel.focusOffset;
    }
    const savedAbs = caretAbsOffsetFromBody(body);
    // Scroll preservation: unwrap → normalize → wrap reshapes the DOM
    // around the caret. Browsers (incl. WebView2) often scroll the new
    // selection into view, which yanks the editor viewport to the top
    // of the document when the restored caret lands above the previous
    // viewport. Save scrollTop on both the editor doc's scrolling element
    // AND the body (TinyMCE sometimes uses one or the other depending
    // on theme/skin) and restore them in the finally block. Bob 2026-05-12:
    // "the message that shows is different. It jumped to the top."
    const scroller = doc.scrollingElement || doc.documentElement;
    const savedScrollTop = scroller?.scrollTop ?? 0;
    const savedBodyScrollTop = body.scrollTop;
    try {
        editor.undoManager?.ignore?.(() => {
            // 1. Unwrap any existing markers — we'll re-wrap fresh based
            //    on the current content. Cheaper than incremental update
            //    and avoids stale markers if the user fixed a word
            //    without going through our menu (just retyped it).
            const old = body.querySelectorAll(`span[${MARKER_ATTR}]`);
            for (const m of old) {
                const parent = m.parentNode;
                if (!parent)
                    continue;
                while (m.firstChild)
                    parent.insertBefore(m.firstChild, m);
                parent.removeChild(m);
            }
            // Merge text nodes that were split by the now-removed spans.
            body.normalize();
            // 2. Walk text nodes and collect words to wrap.
            const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
                acceptNode(node) {
                    let p = node.parentNode;
                    while (p && p !== body) {
                        if (p.nodeType === Node.ELEMENT_NODE && SKIP_TAGS.has(p.tagName)) {
                            return NodeFilter.FILTER_REJECT;
                        }
                        p = p.parentNode;
                    }
                    return NodeFilter.FILTER_ACCEPT;
                },
            });
            // Capture the caret position so we can skip flagging the
            // word currently being typed. Without this, every keystroke
            // mid-word produces a red underline that grows as the user
            // types — exactly the "twiddling" Bob called out. Stripping
            // existing markers (step 1 above) plus this skip means a
            // freshly-typed word stays clean; once the caret leaves it
            // (space, punctuation, arrow keys) the next pass will mark
            // it if still misspelled.
            //
            // After body.normalize() in step 1, the live selection's
            // focusNode may have been collapsed away — re-read it now.
            let caretNode = null;
            let caretOffset = 0;
            const liveSel = doc.getSelection();
            if (liveSel && liveSel.rangeCount > 0) {
                const f = liveSel.focusNode;
                if (f && f.nodeType === Node.TEXT_NODE) {
                    caretNode = f;
                    caretOffset = liveSel.focusOffset;
                }
            }
            const hits = [];
            let n = walker.nextNode();
            // Letter / digit / apostrophe / hyphen — tokenize words via
            // Unicode-aware regex so we don't false-flag accented words.
            const WORD_RE = /[\p{L}][\p{L}'’\-]*/gu;
            // Email addresses aren't natural-language words. Without this,
            // `bob@example.com` tokenizes to `bob` / `example` / `com` and
            // each gets spell-checked (and usually redlined). Find email
            // spans per text node up front and skip any word hit inside one.
            const EMAIL_RE = /[^\s@<>()]+@[^\s@<>()]+\.[^\s@<>()]+/g;
            while (n) {
                const tn = n;
                const text = tn.data;
                const emailRanges = [];
                EMAIL_RE.lastIndex = 0;
                let em;
                while ((em = EMAIL_RE.exec(text)) !== null) {
                    emailRanges.push([em.index, em.index + em[0].length]);
                }
                let m;
                WORD_RE.lastIndex = 0;
                while ((m = WORD_RE.exec(text)) !== null) {
                    const word = m[0];
                    if (word.length < MIN_WORD_LEN)
                        continue;
                    // Inside an email address — not a word to check.
                    const wStart = m.index, wEnd = m.index + word.length;
                    if (emailRanges.some(([s, e]) => wStart < e && wEnd > s))
                        continue;
                    // Skip the word the caret is sitting inside (or
                    // immediately adjacent to — inclusive on both ends).
                    if (caretNode === tn
                        && caretOffset >= m.index
                        && caretOffset <= m.index + word.length) {
                        continue;
                    }
                    if (sp.correct(word))
                        continue;
                    hits.push({ node: tn, start: m.index, end: m.index + word.length });
                }
                n = walker.nextNode();
            }
            // 3. Wrap hits in reverse order — wrapping a span splits the
            //    text node, which would invalidate earlier offsets. Going
            //    right-to-left keeps the not-yet-touched offsets valid.
            hits.reverse();
            for (const h of hits) {
                const range = doc.createRange();
                range.setStart(h.node, h.start);
                range.setEnd(h.node, h.end);
                const span = doc.createElement("span");
                span.setAttribute(MARKER_ATTR, "1");
                try {
                    range.surroundContents(span);
                }
                catch { /* range spans a node boundary — rare; skip */ }
            }
        });
    }
    finally {
        // Prefer the node-reference path when it still attaches to the body.
        // Falls back to abs-offset only when the saved node was inside a
        // span that got unwrapped/rewrapped during the pass.
        let restored = false;
        if (savedFocusNode && body.contains(savedFocusNode)) {
            try {
                const range = doc.createRange();
                const maxOffset = savedFocusNode.nodeType === Node.TEXT_NODE
                    ? savedFocusNode.data.length
                    : savedFocusNode.childNodes.length;
                range.setStart(savedFocusNode, Math.min(savedFocusOffset, maxOffset));
                range.collapse(true);
                const sel = doc.getSelection();
                if (sel) {
                    sel.removeAllRanges();
                    sel.addRange(range);
                    restored = true;
                }
            }
            catch { /* fall through to abs-offset path */ }
        }
        if (!restored && savedAbs != null)
            restoreCaretFromAbsOffset(body, savedAbs);
        // Restore scroll positions AFTER the caret restore — setting the
        // caret may itself trigger scrollIntoView; setting scrollTop last
        // wins. Both targets get restored because the active scroller
        // differs across TinyMCE skins.
        if (scroller && scroller.scrollTop !== savedScrollTop)
            scroller.scrollTop = savedScrollTop;
        if (body.scrollTop !== savedBodyScrollTop)
            body.scrollTop = savedBodyScrollTop;
    }
}
/** Live focus position → absolute character offset from `body`. Walks all
 *  text nodes, accumulates lengths until reaching focusNode, then adds
 *  focusOffset. Returns null when no selection or selection isn't in a
 *  text node we can find. */
function caretAbsOffsetFromBody(body) {
    const doc = body.ownerDocument;
    const sel = doc.getSelection();
    if (!sel || sel.rangeCount === 0)
        return null;
    const focusNode = sel.focusNode;
    const focusOffset = sel.focusOffset;
    if (!focusNode)
        return null;
    // Caret on element node (rare for typing-time decoration) — fall back
    // to "offset" being a child index; treat as zero contribution.
    if (focusNode.nodeType !== Node.TEXT_NODE) {
        // Walk only text BEFORE the focus point.
        let abs = 0;
        const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
        let n = walker.nextNode();
        while (n) {
            if (focusNode.contains(n))
                break;
            // n strictly precedes focusNode in tree order?
            const cmp = focusNode.compareDocumentPosition(n);
            if (cmp & Node.DOCUMENT_POSITION_PRECEDING)
                abs += n.data.length;
            n = walker.nextNode();
        }
        return abs;
    }
    let abs = 0;
    const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
    let n = walker.nextNode();
    while (n) {
        if (n === focusNode)
            return abs + focusOffset;
        abs += n.data.length;
        n = walker.nextNode();
    }
    return null;
}
/** Restore caret to the absolute character offset from `body`. Walks
 *  text nodes until the cumulative length crosses `abs`, then collapses
 *  the selection there. No-op if abs is out of range. */
function restoreCaretFromAbsOffset(body, abs) {
    const doc = body.ownerDocument;
    const sel = doc.getSelection();
    if (!sel)
        return;
    const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
    let acc = 0;
    let n = walker.nextNode();
    while (n) {
        const len = n.data.length;
        if (acc + len >= abs) {
            const range = doc.createRange();
            range.setStart(n, Math.max(0, abs - acc));
            range.collapse(true);
            sel.removeAllRanges();
            sel.addRange(range);
            return;
        }
        acc += len;
        n = walker.nextNode();
    }
    // abs past end — drop caret at end of last text node if any.
    const last = walker.previousNode();
    if (last) {
        const range = doc.createRange();
        range.setStart(last, last.data.length);
        range.collapse(true);
        sel.removeAllRanges();
        sel.addRange(range);
    }
}
/** Inject the wavy-red CSS into the editor iframe. */
function installDecorationStyle(editor) {
    const doc = editor.getDoc?.();
    if (!doc)
        return;
    if (doc.getElementById("mailx-spell-style"))
        return;
    const style = doc.createElement("style");
    style.id = "mailx-spell-style";
    style.textContent = `
        span[${MARKER_ATTR}] {
            text-decoration: underline wavy #d33;
            text-decoration-skip-ink: none;
            text-underline-offset: 2px;
            /* No background — keeps the styling subtle, like a native
             * spell underline, not a Find-highlight. */
            background: transparent;
        }
    `;
    doc.head.appendChild(style);
}
/** Strip decoration markers from serialized output. TinyMCE fires
 *  attribute filters during getContent / draft-save; this filter
 *  unwraps the span so the saved/sent HTML carries only the text. */
function installSerializerFilter(editor) {
    if (editor.__mailxSpellSerializerWired)
        return;
    editor.__mailxSpellSerializerWired = true;
    try {
        editor.serializer.addAttributeFilter(MARKER_ATTR, (nodes) => {
            for (const node of nodes) {
                // TinyMCE's html-node API: `unwrap()` replaces the node
                // with its children. Exactly what we want — keep the
                // text, drop the span.
                if (typeof node.unwrap === "function")
                    node.unwrap();
            }
        });
    }
    catch (e) {
        console.warn("[spellcheck] serializer filter setup failed:", e);
    }
}
// ── Context menu ──────────────────────────────────────────────────
function showSuggestionsMenu(parentDoc, x, y, items) {
    parentDoc.getElementById("mailx-spell-menu")?.remove();
    const menu = parentDoc.createElement("div");
    menu.id = "mailx-spell-menu";
    menu.style.cssText = `
        position: fixed;
        left: ${x}px; top: ${y}px;
        z-index: 10000;
        background: var(--color-bg, #fff);
        color: var(--color-text, #222);
        border: 1px solid var(--color-border, #ccc);
        border-radius: 6px;
        box-shadow: 0 4px 16px rgba(0,0,0,0.18);
        padding: 4px 0;
        font: 13px system-ui, sans-serif;
        min-width: 180px;
        max-width: 320px;
    `;
    for (const it of items) {
        if (it.separator) {
            const sep = parentDoc.createElement("div");
            sep.style.cssText = "border-top:1px solid var(--color-border,#ddd); margin: 4px 0;";
            menu.appendChild(sep);
            continue;
        }
        const btn = parentDoc.createElement("button");
        btn.type = "button";
        btn.textContent = it.label;
        btn.style.cssText = `
            display: block; width: 100%; text-align: left;
            padding: 5px 12px; border: none; background: none;
            color: inherit; cursor: pointer; font: inherit;
            ${it.emphasized ? "font-weight: 600;" : ""}
        `;
        btn.addEventListener("mouseenter", () => { btn.style.background = "var(--color-bg-hover, #eef)"; });
        btn.addEventListener("mouseleave", () => { btn.style.background = "none"; });
        btn.addEventListener("click", () => {
            try {
                it.action();
            }
            finally {
                menu.remove();
            }
        });
        menu.appendChild(btn);
    }
    parentDoc.body.appendChild(menu);
    const r = menu.getBoundingClientRect();
    if (r.right > window.innerWidth)
        menu.style.left = `${Math.max(8, window.innerWidth - r.width - 8)}px`;
    if (r.bottom > window.innerHeight)
        menu.style.top = `${Math.max(8, window.innerHeight - r.height - 8)}px`;
    // Dismiss listeners on EVERY document the user could plausibly click
    // into: the compose document (parentDoc, where the menu is rendered),
    // the TinyMCE editor iframe doc (where the right-click originated and
    // where the user's caret usually lives), and the top mailx window
    // (outside the compose overlay entirely). Without the editor-iframe
    // and top-doc listeners, clicking back into the editor or onto the
    // folder list left the menu pinned (Bob 2026-05-12: "when I click
    // outside this menu why isn't it going away?").
    const docs = [parentDoc];
    try {
        const composeWin = parentDoc.defaultView;
        if (composeWin?.frameElement && composeWin.parent?.document
            && composeWin.parent.document !== parentDoc) {
            docs.push(composeWin.parent.document);
        }
    }
    catch { /* cross-origin — ignore */ }
    // Editor iframe sits inside parentDoc; locate it via TinyMCE convention
    // (.tox-edit-area iframe) or fall back to first iframe in the body.
    try {
        const editorIframe = parentDoc.querySelector("iframe.tox-edit-area__iframe")
            || parentDoc.querySelector("iframe");
        const editorDoc = editorIframe?.contentDocument;
        if (editorDoc && editorDoc !== parentDoc)
            docs.push(editorDoc);
    }
    catch { /* */ }
    const dismiss = (e) => {
        if (e.type === "keydown" && e.key !== "Escape")
            return;
        if (e.type === "mousedown" && menu.contains(e.target))
            return;
        menu.remove();
        for (const d of docs) {
            d.removeEventListener("mousedown", dismiss, true);
            d.removeEventListener("keydown", dismiss, true);
        }
    };
    setTimeout(() => {
        for (const d of docs) {
            d.addEventListener("mousedown", dismiss, true);
            d.addEventListener("keydown", dismiss, true);
        }
    }, 0);
}
/** Replace a misspelling marker span with the correction. Done via
 *  range-based selection + insertText so TinyMCE's undo stack and
 *  dirty-tracking pick it up properly. */
function replaceMarker(editor, marker, replacement) {
    const doc = editor.getDoc();
    const range = doc.createRange();
    range.selectNode(marker);
    const sel = doc.getSelection();
    if (!sel)
        return;
    sel.removeAllRanges();
    sel.addRange(range);
    try {
        if (!doc.execCommand("insertText", false, replacement)) {
            range.deleteContents();
            range.insertNode(doc.createTextNode(replacement));
        }
    }
    catch {
        range.deleteContents();
        range.insertNode(doc.createTextNode(replacement));
    }
}
/** Fast removal-only pass: unwrap any misspelling marker whose word is now
 *  correct (or no longer a single word). Runs on the short CLEANUP debounce.
 *  Safe at a short interval because it only ever *removes* underlines —
 *  unlike decorate(), which adds them and so waits the calmer 1200 ms.
 *  This is what makes a corrected word's red line vanish promptly. */
function cleanupCorrected(editor, sp) {
    const body = editor.getBody?.();
    const doc = editor.getDoc?.();
    if (!body || !doc)
        return;
    const markers = body.querySelectorAll(`span[${MARKER_ATTR}]`);
    if (markers.length === 0)
        return;
    // The marker the caret is inside is being actively edited — leave it.
    let caretMarker = null;
    const sel = doc.getSelection();
    if (sel && sel.rangeCount > 0) {
        let p = sel.focusNode;
        while (p && p !== body) {
            if (p.nodeType === Node.ELEMENT_NODE && p.hasAttribute?.(MARKER_ATTR)) {
                caretMarker = p;
                break;
            }
            p = p.parentNode;
        }
    }
    const stale = [];
    for (const m of markers) {
        const word = m.textContent || "";
        // Now correct, emptied, or split by editing into multiple tokens.
        const isStale = !word || /\s/.test(word) || sp.correct(word);
        // Caret protection applies ONLY to non-stale markers — i.e. the
        // user actively mid-correcting a real misspelling. A stale marker
        // (whitespace inside = it's grown past the original misspelled
        // word, or contents are now correct) is unwrapped regardless;
        // the caret survives because unwrap leaves the text node in place
        // (moved to the marker's parent at the same offset). Without
        // this, a marker that swallowed adjacent typing kept its red
        // wavy underline until the user moved the caret out + the 1200 ms
        // decorate debounce — "took a very long time" (Bob 2026-05-22).
        if (!isStale && m === caretMarker)
            continue;
        if (isStale)
            stale.push(m);
    }
    if (stale.length === 0)
        return;
    // Unwrap only — no re-walk, no body.normalize() — so the live caret
    // Range stays valid (we never touch the caret's own marker, and a plain
    // unwrap leaves text-node offsets intact). No caret save/restore needed.
    editor.undoManager?.ignore?.(() => {
        for (const m of stale) {
            const parent = m.parentNode;
            if (!parent)
                continue;
            while (m.firstChild)
                parent.insertBefore(m.firstChild, m);
            parent.removeChild(m);
        }
    });
}
// ── Public entry point ────────────────────────────────────────────
/** Wire the spell-check into a TinyMCE editor instance. Idempotent. */
export function wireSpellcheck(editor) {
    if (editor.__mailxSpellWired)
        return;
    editor.__mailxSpellWired = true;
    // Kill the native (Chromium/WebView2) spellchecker on this editor. mailx
    // runs its own nspell checker; leaving the native one on too double-
    // underlines every word AND pops the native suggestion menu instead of
    // ours (Bob 2026-05-18: "why so much red twiddle" / "spell fixing is
    // broken?"). Force spellcheck="false" on the body and keep it off — some
    // WebView2 builds re-enable it, hence the observer.
    const killNativeSpellcheck = () => {
        try {
            const body = editor.getBody?.();
            if (body && body.getAttribute("spellcheck") !== "false") {
                body.setAttribute("spellcheck", "false");
            }
        }
        catch { /* editor not ready — retried by the observer / events */ }
    };
    killNativeSpellcheck();
    try {
        const body = editor.getBody?.();
        if (body)
            new MutationObserver(killNativeSpellcheck)
                .observe(body, { attributes: true, attributeFilter: ["spellcheck"] });
    }
    catch { /* */ }
    let sp = null;
    let decorateTimer = null;
    const scheduleDecorate = () => {
        if (!sp)
            return;
        if (decorateTimer)
            clearTimeout(decorateTimer);
        decorateTimer = setTimeout(() => {
            decorateTimer = null;
            if (sp)
                decorate(editor, sp);
        }, DECORATE_DEBOUNCE_MS);
    };
    // Short-debounce removal-only pass so a corrected word's red line clears
    // promptly without waiting on the full (add+remove) decorate debounce.
    let cleanupTimer = null;
    const scheduleCleanup = () => {
        if (!sp)
            return;
        if (cleanupTimer)
            clearTimeout(cleanupTimer);
        cleanupTimer = setTimeout(() => {
            cleanupTimer = null;
            if (sp)
                cleanupCorrected(editor, sp);
        }, CLEANUP_DEBOUNCE_MS);
    };
    // Kick off the dictionary load. First decoration runs as soon as
    // it resolves; subsequent runs are triggered by editor events.
    getSpell().then((loaded) => {
        sp = loaded;
        installDecorationStyle(editor);
        installSerializerFilter(editor);
        decorate(editor, loaded);
    }).catch((err) => {
        console.error("[spellcheck] dict load failed:", err);
    });
    // Re-decorate on edits / paste / content swap. `nodechange` covers
    // most of these; `input` catches typing in finer-grained events on
    // some TinyMCE versions. The short cleanup pass runs on the same
    // events so a just-corrected word loses its underline quickly.
    editor.on("input nodechange setcontent paste keyup", scheduleDecorate);
    editor.on("input nodechange setcontent paste keyup", scheduleCleanup);
    // Right-click handler. If the click landed on a marker span, show
    // suggestions; otherwise let the default menu (WebView2's) fire.
    const iframeDoc = editor.getDoc();
    iframeDoc.addEventListener("contextmenu", (ev) => {
        const e = ev;
        const target = e.target;
        if (!target)
            return;
        const marker = target.closest?.(`span[${MARKER_ATTR}]`);
        if (!marker)
            return; // not on a misspelled word — default menu fires
        const word = marker.textContent || "";
        if (!word || !sp)
            return;
        e.preventDefault();
        e.stopPropagation();
        // nspell's suggest() leans on Hunspell's TRY/REP heuristics and
        // doesn't reliably surface adjacent-letter transpositions — the
        // single most common English typo (Bob 2026-05-12: "what kind of
        // a spell is this if it can't see hte is the"). Prepend our own
        // transposition pass: for each adjacent pair in the word, swap
        // them and keep results that exist in the dictionary. Then merge
        // with nspell's list, transpositions first, dedup, cap at 7.
        const transposed = [];
        for (let i = 0; i < word.length - 1; i++) {
            const swapped = word.slice(0, i) + word[i + 1] + word[i] + word.slice(i + 2);
            if (swapped !== word && sp.correct(swapped) && !transposed.includes(swapped)) {
                transposed.push(swapped);
            }
        }
        const nspellSugs = sp.suggest(word);
        const sugs = [];
        for (const s of [...transposed, ...nspellSugs]) {
            if (!sugs.includes(s))
                sugs.push(s);
            if (sugs.length >= 7)
                break;
        }
        const iframeEl = editor.iframeElement;
        const iframeRect = iframeEl ? iframeEl.getBoundingClientRect() : { left: 0, top: 0 };
        const items = [];
        if (sugs.length === 0) {
            items.push({ label: "(no suggestions)", action: () => { } });
        }
        else {
            for (const s of sugs) {
                items.push({
                    label: s,
                    emphasized: true,
                    action: () => {
                        replaceMarker(editor, marker, s);
                        // Re-decorate so the replacement is checked too.
                        scheduleDecorate();
                    },
                });
            }
        }
        items.push({ label: "", action: () => { }, separator: true });
        items.push({
            label: `Add "${word}" to dictionary`,
            action: () => { if (sp)
                addToUserDict(word, sp); scheduleDecorate(); },
        });
        items.push({
            label: "Ignore (this session)",
            action: () => { if (sp)
                sp.add(word); scheduleDecorate(); },
        });
        showSuggestionsMenu(document, iframeRect.left + e.clientX, iframeRect.top + e.clientY, items);
    }, true);
}
//# sourceMappingURL=spellcheck.js.map