/**
 * Live grammar squiggles in compose via Harper (offline; the 18 MB WASM
 * engine runs in the DAEMON — see mailx-service grammarLint — so this module
 * costs the compose page nothing but IPC calls and a handful of divs).
 *
 * ONE implementation for all three editors (Quill / tiptap / TinyMCE): they
 * are all contenteditable DOM. Per-editor knowledge is limited to finding
 * the editable root (TinyMCE = iframe body via nativeEditor.getBody()).
 * Squiggles are painted in an OVERLAY (never inside the editable subtree —
 * that would leak markup into the outgoing message); positions come from
 * Range.getClientRects(), so nothing mutates editor state and the failure
 * mode of every path is "no squiggle", never a broken compose.
 *
 * Plan + phasing: docs/harper-integration.md (TODO C159).
 */
import type { MailxEditor } from "./editor.js";
import { grammarLint } from "../lib/api-client.js";

type WireLint = { start: number; end: number; kind: string; message: string; suggestions: string[] };
type Placed = { lint: WireLint; range: Range };

const DEBOUNCE_MS = 700;
const OVERLAY_ID = "harper-overlay";
const POPUP_ID = "harper-popup";
// Spelling squiggles stay with the native WebView2 spellchecker (red, with
// its own suggestion menu) — double-underlining every typo helps no one.
const SKIP_KINDS = new Set(["Spelling"]);

export function initHarperLint(ed: MailxEditor): () => void {
    let disposed = false;
    let timer: ReturnType<typeof setTimeout> | null = null;
    let placed: Placed[] = [];
    let paintQueued = false;
    let bridgeDead = false;   // host has no grammarLint (Android today) — go silent

    const target = (): { root: HTMLElement; doc: Document } | null => {
        try {
            const nat: any = ed.nativeEditor;
            if (nat?.getBody) {   // TinyMCE: editable body inside its iframe
                const b = nat.getBody();
                return b ? { root: b, doc: b.ownerDocument } : null;
            }
            return ed.root ? { root: ed.root, doc: ed.root.ownerDocument } : null;
        } catch { return null; }
    };

    /** Overlay host — html element for TinyMCE (its body IS the editable;
     *  divs appended there would serialize into the message), page body for
     *  in-page editors. */
    const overlayHost = (doc: Document, root: HTMLElement): HTMLElement =>
        (root === doc.body ? doc.documentElement : doc.body);

    const clearOverlay = (doc: Document): void => {
        doc.getElementById(OVERLAY_ID)?.remove();
        doc.getElementById(POPUP_ID)?.remove();
    };

    /** Blocks = top-level element children of the editable root (or the root
     *  itself when it has none). Quoted reply chains are the author's
     *  correspondent's text — skip them (also keeps lint cost proportional
     *  to what's actually being written). */
    const collectBlocks = (root: HTMLElement): HTMLElement[] => {
        const kids = Array.from(root.children).filter((c): c is HTMLElement => c instanceof HTMLElement);
        const blocks = (kids.length ? kids : [root]).filter(b =>
            b.tagName !== "BLOCKQUOTE" && !b.closest("blockquote") && !b.querySelector("blockquote"));
        return blocks;
    };

    /** Flatten a block to plain text + a map back to its text nodes. */
    const blockText = (block: HTMLElement, doc: Document): { text: string; nodes: { node: Text; from: number }[] } => {
        const walker = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT);
        const nodes: { node: Text; from: number }[] = [];
        let text = "";
        for (let n = walker.nextNode(); n; n = walker.nextNode()) {
            nodes.push({ node: n as Text, from: text.length });
            text += (n as Text).data;
        }
        return { text, nodes };
    };

    const spanToRange = (doc: Document, nodes: { node: Text; from: number }[], start: number, end: number): Range | null => {
        let a: { node: Text; off: number } | null = null;
        let b: { node: Text; off: number } | null = null;
        for (const e of nodes) {
            const len = e.node.data.length;
            if (!a && start >= e.from && start <= e.from + len) a = { node: e.node, off: start - e.from };
            if (end >= e.from && end <= e.from + len) b = { node: e.node, off: end - e.from };
        }
        if (!a || !b) return null;
        try {
            const r = doc.createRange();
            r.setStart(a.node, a.off);
            r.setEnd(b.node, b.off);
            return r;
        } catch { return null; }
    };

    const paint = (): void => {
        if (disposed) return;
        const t = target();
        if (!t) return;
        clearOverlay(t.doc);
        if (placed.length === 0) return;
        const host = overlayHost(t.doc, t.root);
        const ov = t.doc.createElement("div");
        ov.id = OVERLAY_ID;
        ov.style.cssText = "position:absolute;left:0;top:0;width:0;height:0;overflow:visible;pointer-events:none;z-index:5;";
        const win = t.doc.defaultView || window;
        for (const p of placed) {
            let rects: DOMRectList | DOMRect[] = [];
            try { rects = p.range.getClientRects(); } catch { continue; }
            for (const r of Array.from(rects)) {
                if (r.width < 2) continue;
                const u = t.doc.createElement("div");
                // Dotted blue-ish underline — distinct from the native
                // spellchecker's red squiggle. Hardcoded color: TinyMCE's
                // iframe can't see the page's CSS variables.
                u.style.cssText = `position:absolute;left:${r.left + win.scrollX}px;top:${r.bottom + win.scrollY - 2}px;width:${r.width}px;height:0;border-bottom:2px dotted rgba(66,133,244,0.9);pointer-events:none;`;
                ov.appendChild(u);
            }
        }
        host.appendChild(ov);
    };

    const queuePaint = (): void => {
        if (paintQueued || disposed) return;
        paintQueued = true;
        requestAnimationFrame(() => { paintQueued = false; paint(); });
    };

    const lint = async (): Promise<void> => {
        if (disposed || bridgeDead) return;
        const t = target();
        if (!t) return;
        const blocks = collectBlocks(t.root);
        const texts = blocks.map(b => blockText(b, t.doc));
        let res: Awaited<ReturnType<typeof grammarLint>>;
        try {
            res = await grammarLint(texts.map(x => x.text));
        } catch {
            // Host without grammarLint (Android bridge today) or IPC failure:
            // feature goes quietly dark for this compose session.
            bridgeDead = true;
            clearOverlayAll();
            return;
        }
        if (disposed) return;
        const next: Placed[] = [];
        (res?.blocks || []).forEach((lints, i) => {
            const tx = texts[i];
            if (!tx) return;
            for (const l of lints || []) {
                if (SKIP_KINDS.has(l.kind)) continue;
                const range = spanToRange(t.doc, tx.nodes, l.start, l.end);
                if (range) next.push({ lint: l, range });
            }
        });
        placed = next;
        queuePaint();
    };

    const clearOverlayAll = (): void => {
        const t = target();
        if (t) clearOverlay(t.doc);
        placed = [];
    };

    const schedule = (): void => {
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => { void lint(); }, DEBOUNCE_MS);
    };

    /** Apply a suggestion. Prefer the editor's own API (undo coherence);
     *  fall back to execCommand insertText, which all three engines accept
     *  as user input. */
    const applyFix = (p: Placed, replacement: string, doc: Document): void => {
        try {
            const nat: any = ed.nativeEditor;
            if (nat?.selection?.setRng && nat?.insertContent) {   // TinyMCE
                nat.selection.setRng(p.range);
                const esc = replacement.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
                nat.insertContent(esc);
            } else {
                const sel = (doc.defaultView || window).getSelection();
                if (!sel) return;
                sel.removeAllRanges();
                sel.addRange(p.range);
                doc.execCommand("insertText", false, replacement);
            }
        } catch { /* squiggle stays; user can edit manually */ }
        doc.getElementById(POPUP_ID)?.remove();
        schedule();
    };

    const showPopup = (p: Placed, x: number, y: number, doc: Document): void => {
        doc.getElementById(POPUP_ID)?.remove();
        const pop = doc.createElement("div");
        pop.id = POPUP_ID;
        pop.style.cssText = `position:absolute;left:${x}px;top:${y + 6}px;z-index:2100;background:#fff;color:#1a1a1a;border:1px solid #bbb;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.25);padding:6px 0;min-width:180px;max-width:340px;font:13px system-ui,sans-serif;`;
        const msg = doc.createElement("div");
        msg.style.cssText = "padding:4px 12px 6px;color:#555;border-bottom:1px solid #eee;";
        msg.textContent = p.lint.message;
        pop.appendChild(msg);
        for (const s of p.lint.suggestions.slice(0, 5)) {
            const item = doc.createElement("div");
            item.style.cssText = "padding:6px 12px;cursor:pointer;font-weight:600;";
            item.textContent = s;
            item.addEventListener("mouseenter", () => { item.style.background = "#eef3ff"; });
            item.addEventListener("mouseleave", () => { item.style.background = ""; });
            item.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); applyFix(p, s, doc); });
            pop.appendChild(item);
        }
        if (p.lint.suggestions.length === 0) {
            const none = doc.createElement("div");
            none.style.cssText = "padding:6px 12px;color:#888;";
            none.textContent = "(no suggestion — reword manually)";
            pop.appendChild(none);
        }
        overlayHost(doc, target()?.root || doc.body).appendChild(pop);
        const dismiss = (): void => { pop.remove(); doc.removeEventListener("mousedown", dismiss, true); };
        setTimeout(() => doc.addEventListener("mousedown", dismiss, true), 0);
    };

    const onClick = (e: MouseEvent): void => {
        if (disposed || placed.length === 0) return;
        const t = target();
        if (!t) return;
        for (const p of placed) {
            let rects: DOMRect[] = [];
            try { rects = Array.from(p.range.getClientRects()); } catch { continue; }
            for (const r of rects) {
                // Small grace margin so the underline itself is clickable.
                if (e.clientX >= r.left - 2 && e.clientX <= r.right + 2 && e.clientY >= r.top - 2 && e.clientY <= r.bottom + 4) {
                    const win = t.doc.defaultView || window;
                    showPopup(p, r.left + win.scrollX, r.bottom + win.scrollY, t.doc);
                    return;
                }
            }
        }
    };

    // ── Wiring ──
    const t0 = target();
    ed.onContentChange(schedule);
    if (t0) {
        t0.doc.addEventListener("click", onClick);
        t0.doc.addEventListener("scroll", queuePaint, true);
        (t0.doc.defaultView || window).addEventListener("resize", queuePaint);
    }
    try { ed.getScrollContainer()?.addEventListener("scroll", queuePaint); } catch { /* */ }
    // First pass once the editor has settled (reply bodies arrive async).
    setTimeout(() => { void lint(); }, 1500);

    return () => {
        disposed = true;
        if (timer) clearTimeout(timer);
        clearOverlayAll();
        const t = target();
        if (t) {
            t.doc.removeEventListener("click", onClick);
            t.doc.removeEventListener("scroll", queuePaint, true);
            (t.doc.defaultView || window).removeEventListener("resize", queuePaint);
        }
    };
}
