/**
 * Spell-check for the TinyMCE compose editor — NON-MUTATING OVERLAY.
 *
 * Why this rewrite (Bob 2026-06-12): the previous version drew the red
 * squiggles by WRAPPING each misspelled word in a `<span>` inside the live
 * contenteditable, on a debounce, while the user typed — then tried to put
 * the caret back by re-counting characters. That approach fought the user's
 * typing: it yanked the cursor to a different position, and because it
 * reshaped the DOM between keystrokes it corrupted TinyMCE's undo stack so a
 * single Ctrl+Z reverted a whole reshape instead of the last few characters.
 * It had been patched ~10 times and still regressed. The editor became
 * untrustworthy.
 *
 * The fix is to stop editing the content at all. We:
 *   1. READ the body's text nodes (TreeWalker) and ask nspell which words are
 *      misspelled — no mutation.
 *   2. Measure each misspelled word's on-screen rectangle with a DOM Range's
 *      getClientRects(), and draw a wavy red underline at that rectangle in a
 *      SEPARATE overlay layer.
 *
 * The overlay is a single `<div>` appended to the body but marked
 * `contenteditable="false"` + `data-mce-bogus="all"` (TinyMCE excludes bogus
 * elements from both serialization and the undo snapshot) and
 * `pointer-events:none` (the caret can never enter it, clicks pass through to
 * the text). Mutating it therefore can't move the cursor, can't pollute undo,
 * and can't leak into the sent message or the saved draft. The squiggles are
 * absolutely positioned in the body's content coordinate space, so they scroll
 * with the text without any per-scroll work.
 *
 * Right-click uses getWordAtPoint() (find the word under the click directly),
 * so there are no marker spans to hang suggestions off of. Applying a
 * correction goes through TinyMCE's own selection + insertContent so it's
 * undo-tracked and focus-safe.
 *
 * Dictionary load, suggestion building, the floating menu, word-at-point, and
 * user-dictionary persistence are all shared with the Quill adapter via
 * spellcheck-core.ts.
 */
import { getSpell, addToUserDict, buildSuggestionList, showSuggestionsMenu, getWordAtPoint, isWordCorrect, findSkipRanges, inSkipRange, MIN_WORD_LEN, SKIP_TAGS, } from "./spellcheck-core.js";
import { standardEditItems } from "./edit-commands.js";
// Re-scan after the user pauses. Long enough not to fire mid-word; short
// enough to feel responsive. Nothing about this debounce affects typing
// stability anymore (the scan never touches the content), so it's purely a
// CPU/perf knob.
const SCAN_DEBOUNCE_MS = 600;
// Red wavy underline: a 6x3 SVG wave tiled horizontally under the word.
// encodeURIComponent keeps the inline SVG valid inside a CSS url().
const WAVE = `url("data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="6" height="3">' +
    '<path d="M0 2 Q1.5 0 3 2 T6 2" stroke="#d33" fill="none" stroke-width="1"/></svg>')}")`;
const OVERLAY_ID = "mailx-spell-overlay";
/** Wire the spell-check into a TinyMCE editor instance. Idempotent. */
export function wireSpellcheck(editor) {
    if (editor.__mailxSpellWired)
        return;
    editor.__mailxSpellWired = true;
    let sp = null;
    // Kill the native (Chromium/WebView2) spellchecker on this editor so we
    // don't get two sets of underlines / the native suggestion menu instead of
    // ours. Some WebView2 builds re-enable it, hence the observer.
    const killNative = () => {
        try {
            const body = editor.getBody?.();
            if (body && body.getAttribute("spellcheck") !== "false") {
                body.setAttribute("spellcheck", "false");
            }
        }
        catch { /* editor not ready */ }
    };
    killNative();
    try {
        const body = editor.getBody?.();
        if (body)
            new MutationObserver(killNative)
                .observe(body, { attributes: true, attributeFilter: ["spellcheck"] });
    }
    catch { /* */ }
    // The overlay layer. Bogus + non-editable + pointer-events:none so it's
    // invisible to serialization, undo, the caret, and the mouse.
    const ensureOverlay = () => {
        const body = editor.getBody?.();
        const doc = editor.getDoc?.();
        if (!body || !doc)
            return null;
        let ov = doc.getElementById(OVERLAY_ID);
        if (!ov || ov.parentNode !== body) {
            ov?.remove();
            ov = doc.createElement("div");
            ov.id = OVERLAY_ID;
            ov.setAttribute("contenteditable", "false");
            ov.setAttribute("data-mce-bogus", "all");
            ov.style.cssText = "position:absolute;top:0;left:0;pointer-events:none;user-select:none;";
            body.appendChild(ov);
        }
        return ov;
    };
    // Read the content, find misspelled words, draw squiggles. Reads the
    // content DOM (TreeWalker + Range measurement) but writes ONLY to the
    // bogus overlay — never the user's text or selection.
    const scan = () => {
        if (!sp)
            return;
        const body = editor.getBody?.();
        const doc = editor.getDoc?.();
        const ov = ensureOverlay();
        if (!body || !doc || !ov)
            return;
        const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
            acceptNode(node) {
                // Skip the overlay's own (none, but defensive) and non-prose
                // containers: quoted reply, code, links, etc.
                let p = node.parentNode;
                while (p && p !== body) {
                    if (p.nodeType === Node.ELEMENT_NODE) {
                        const el = p;
                        if (el.id === OVERLAY_ID)
                            return NodeFilter.FILTER_REJECT;
                        if (SKIP_TAGS.has(el.tagName))
                            return NodeFilter.FILTER_REJECT;
                    }
                    p = p.parentNode;
                }
                return NodeFilter.FILTER_ACCEPT;
            },
        });
        const bodyRect = body.getBoundingClientRect();
        const sx = body.scrollLeft;
        const sy = body.scrollTop;
        const frag = doc.createDocumentFragment();
        // A word starts with a letter, then letters / apostrophes. NO hyphen:
        // hyphenated compounds are spell-checked one part at a time (the
        // hyphenation itself is grammar, not spelling — Bob 2026-07-08).
        const wordRe = /[\p{L}][\p{L}'’]*/gu;
        for (let n = walker.nextNode(); n; n = walker.nextNode()) {
            const text = n.data;
            if (!text || text.length < MIN_WORD_LEN)
                continue;
            // Plain-text URLs and email addresses are never spell-checked —
            // including partially-typed ones (autolinked <a> are already
            // excluded via SKIP_TAGS; this covers text autolink hasn't, or
            // never will, convert — e.g. bare domains).
            const skip = findSkipRanges(text);
            wordRe.lastIndex = 0;
            let m;
            while ((m = wordRe.exec(text))) {
                const w = m[0];
                if (w.length < MIN_WORD_LEN)
                    continue;
                if (inSkipRange(skip, m.index, m.index + w.length))
                    continue;
                if (isWordCorrect(w, sp))
                    continue;
                const r = doc.createRange();
                r.setStart(n, m.index);
                r.setEnd(n, m.index + w.length);
                const rects = r.getClientRects();
                for (let i = 0; i < rects.length; i++) {
                    const rect = rects[i];
                    if (rect.width < 1)
                        continue;
                    const sq = doc.createElement("div");
                    // Content-space coordinates: the overlay is an absolutely
                    // positioned child of the (scrolling) body, so a child placed
                    // at content-space (x,y) tracks the word through scroll with
                    // no per-scroll recompute.
                    sq.style.cssText =
                        "position:absolute;" +
                            `left:${(rect.left - bodyRect.left + sx).toFixed(1)}px;` +
                            `top:${(rect.bottom - bodyRect.top + sy - 3).toFixed(1)}px;` +
                            `width:${rect.width.toFixed(1)}px;height:3px;` +
                            `background:${WAVE} repeat-x left bottom;`;
                    frag.appendChild(sq);
                }
            }
        }
        ov.textContent = "";
        ov.appendChild(frag);
    };
    let scanTimer = null;
    const scheduleScan = () => {
        if (!sp)
            return;
        if (scanTimer)
            clearTimeout(scanTimer);
        scanTimer = setTimeout(() => { scanTimer = null; scan(); }, SCAN_DEBOUNCE_MS);
    };
    getSpell().then((loaded) => { sp = loaded; scan(); })
        .catch((e) => console.error("[spellcheck] dict load failed:", e));
    // Re-scan on any content change. NodeChange is deliberately excluded — it
    // can fire on pure selection moves and we don't want a scan per caret tick;
    // input/keyup cover real edits, and Undo/Redo/SetContent cover the rest.
    editor.on("input keyup paste SetContent Undo Redo", scheduleScan);
    // Reposition after the editor reflows or scrolls.
    editor.on("ResizeEditor", scheduleScan);
    try {
        editor.getDoc()?.addEventListener("scroll", scheduleScan, { passive: true });
    }
    catch { /* */ }
    // Apply a correction: select the word's text-node range and let TinyMCE
    // replace it. editor.focus() + selection + insertContent is undo-tracked
    // and lands even though the suggestion menu (in the top document) stole
    // focus — the same focus-aware path the old replaceMarker needed.
    const apply = (node, start, end, replacement) => {
        try {
            const doc = editor.getDoc();
            const range = doc.createRange();
            range.setStart(node, start);
            range.setEnd(node, end);
            editor.focus();
            editor.selection.setRng(range);
            editor.insertContent(editor.dom.encode(replacement));
        }
        catch {
            // Raw-DOM fallback if the editor API is unavailable.
            try {
                const doc = editor.getDoc();
                const range = doc.createRange();
                range.setStart(node, start);
                range.setEnd(node, end);
                range.deleteContents();
                range.insertNode(doc.createTextNode(replacement));
            }
            catch { /* */ }
        }
        scheduleScan();
    };
    // Right-click: if the click landed on a misspelled word, show suggestions;
    // otherwise let the default (native) menu fire.
    const iframeDoc = editor.getDoc();
    iframeDoc.addEventListener("contextmenu", (ev) => {
        const e = ev;
        const body = editor.getBody?.();
        if (!body || !sp)
            return;
        const hit = getWordAtPoint(body, e.clientX, e.clientY);
        if (!hit)
            return; // not on a word (or in a URL/email)
        if (isWordCorrect(hit.word, sp))
            return; // spelled correctly — no menu
        e.preventDefault();
        e.stopPropagation();
        const sugs = buildSuggestionList(hit.word, sp);
        const items = sugs.length === 0
            ? [{ label: "(no suggestions)", action: () => { } }]
            : sugs.map(s => ({
                label: s,
                emphasized: true,
                action: () => apply(hit.node, hit.start, hit.end, s),
            }));
        items.push({ label: "", action: () => { }, separator: true });
        items.push({
            label: `Add "${hit.word}" to dictionary`,
            action: () => { if (sp)
                addToUserDict(hit.word, sp); scheduleScan(); },
        });
        items.push({
            label: "Ignore (this session)",
            action: () => { if (sp)
                sp.add(hit.word); scheduleScan(); },
        });
        // This menu REPLACED the native one (preventDefault above), so it owes
        // the user everything the native menu would have given: Cut/Copy/Paste
        // plus the formatting entries msger appends. Without this, right-
        // clicking a misspelled word — the most likely right-click in a draft —
        // offered spelling actions and nothing else (Bob 2026-08-07: "what
        // happened to all the previous menu options").
        items.push(...standardEditItems(() => editor, {
            onError: (msg) => { try {
                editor.notificationManager?.open({ text: msg, type: "error", timeout: 6000 });
            }
            catch {
                console.warn(msg);
            } },
            showSource: () => { try {
                editor.execCommand("mceCodeEditor");
            }
            catch { /* code plugin absent */ } },
        }));
        // getWordAtPoint's (x,y) are iframe-local; the menu lives in the top
        // document, so offset by the iframe's position on the page.
        const iframeEl = editor.iframeElement;
        const rect = iframeEl ? iframeEl.getBoundingClientRect() : { left: 0, top: 0 };
        showSuggestionsMenu(document, rect.left + e.clientX, rect.top + e.clientY, items, [iframeDoc]);
    }, true);
}
//# sourceMappingURL=spellcheck.js.map