/**
 * 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, type NSpellInstance, type MenuItem,
} 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: any): void {
    if ((editor as any).__mailxSpellWired) return;
    (editor as any).__mailxSpellWired = true;

    let sp: NSpellInstance | null = 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 = (): void => {
        try {
            const body: HTMLElement | null = editor.getBody?.();
            if (body && body.getAttribute("spellcheck") !== "false") {
                body.setAttribute("spellcheck", "false");
            }
        } catch { /* editor not ready */ }
    };
    killNative();
    try {
        const body: HTMLElement | null = 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 = (): HTMLElement | null => {
        const body: HTMLElement | null = editor.getBody?.();
        const doc: Document | null = editor.getDoc?.();
        if (!body || !doc) return null;
        let ov = doc.getElementById(OVERLAY_ID) as HTMLElement | null;
        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 = (): void => {
        if (!sp) return;
        const body: HTMLElement | null = editor.getBody?.();
        const doc: Document | null = editor.getDoc?.();
        const ov = ensureOverlay();
        if (!body || !doc || !ov) return;

        const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
            acceptNode(node: Node) {
                // Skip the overlay's own (none, but defensive) and non-prose
                // containers: quoted reply, code, links, etc.
                let p: Node | null = node.parentNode;
                while (p && p !== body) {
                    if (p.nodeType === Node.ELEMENT_NODE) {
                        const el = p as Element;
                        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;
            },
        });

        // Measure against the OVERLAY's own origin, not the body's. The
        // squiggles are absolutely positioned children of the overlay, so
        // their coordinates resolve against the overlay's containing block —
        // and TinyMCE's content body is `position: static` with a 16px
        // margin, so that block is the initial containing block at (0,0),
        // not the body box at (16,20). Subtracting bodyRect drew every
        // squiggle 20px high and 16px left of its word — a full line-height
        // off, so it appeared above the previous line (Bob 2026-08-13).
        // The overlay sits at the origin of whatever the containing block
        // turns out to be, so its rect is the right zero point no matter how
        // the content body is styled; and since both rects are read in the
        // same frame, the difference needs no scroll correction (the overlay
        // scrolls with the content it annotates).
        const ovRect = ov.getBoundingClientRect();
        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() as Text | null; n; n = walker.nextNode() as Text | null) {
            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: RegExpExecArray | null;
            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");
                    // Overlay-space coordinates: the overlay tracks the content
                    // through scroll, so a child placed at (x,y) relative to it
                    // stays under its word with no per-scroll recompute.
                    sq.style.cssText =
                        "position:absolute;" +
                        `left:${(rect.left - ovRect.left).toFixed(1)}px;` +
                        `top:${(rect.bottom - ovRect.top - 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: ReturnType<typeof setTimeout> | null = null;
    const scheduleScan = (): void => {
        if (!sp) return;
        if (scanTimer) clearTimeout(scanTimer);
        scanTimer = setTimeout(() => { scanTimer = null; scan(); }, SCAN_DEBOUNCE_MS);
    };

    getSpell().then((loaded: NSpellInstance) => { sp = loaded; scan(); })
        .catch((e: any) => 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 } as AddEventListenerOptions);
    } 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: Text, start: number, end: number, replacement: string): void => {
        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: Document = editor.getDoc();
    iframeDoc.addEventListener("contextmenu", (ev: Event) => {
        const e = ev as MouseEvent;
        const body: HTMLElement | null = 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: MenuItem[] = 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 as HTMLIFrameElement | undefined;
        const rect = iframeEl ? iframeEl.getBoundingClientRect() : ({ left: 0, top: 0 } as DOMRect);
        showSuggestionsMenu(document, rect.left + e.clientX, rect.top + e.clientY, items, [iframeDoc]);
    }, true);
}
