/**
 * Editor-agnostic spell-check core.
 *
 * The pieces that are NOT tied to a specific editor:
 *   - Dictionary load (nspell + hunspell en.aff/en.dic + user-dict from cloud).
 *   - addToUserDict (write-through cache + cloud round-trip).
 *   - showSuggestionsMenu (renders a floating menu in any document).
 *   - getWordAtPoint (finds the word under a click in a contenteditable).
 *   - buildSuggestionList (transposition-first list + nspell.suggest, capped).
 *
 * The pieces that ARE editor-specific (separate per editor):
 *   - DOM walking for live decoration.
 *   - replaceWord — needs the editor's selection model so undo works.
 *   - Serializer filter that strips marker spans (depends on the editor's
 *     output API).
 *
 * spellcheck.ts (TinyMCE) and spellcheck-quill.ts (Quill) both consume
 * this module. Adding a new editor means writing a thin adapter for the
 * editor-specific bits and reusing everything else here.
 */

// @ts-expect-error — nspell ships no type defs.
import NSpell from "nspell";
import { getUserDict, addUserDictWord, addUserDictWords } from "../lib/api-client.js";

export type NSpellInstance = any;

export const USER_DICT_KEY = "mailx-user-dict";
export const MARKER_ATTR = "data-mailx-spellerror";
export const MIN_WORD_LEN = 3;
export const SKIP_TAGS = new Set(["BLOCKQUOTE", "CODE", "PRE", "A", "SCRIPT", "STYLE", "KBD", "SAMP", "VAR"]);

let spellPromise: Promise<NSpellInstance> | null = null;

/** Resolve the shared nspell instance. Loads the en.aff/en.dic Hunspell
 *  files once, layers in the user's dictionary from localStorage + GDrive,
 *  and reconciles any local-only additions back up to the cloud. */
export async function getSpell(): Promise<NSpellInstance> {
    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 as any)({ aff, dic });
        try {
            const raw = localStorage.getItem(USER_DICT_KEY);
            if (raw) for (const w of JSON.parse(raw) as string[]) sp.add(w);
        } catch { /* corrupt cache */ }
        getUserDict().then(cloud => {
            const cloudArr = Array.isArray(cloud) ? cloud : [];
            for (const w of cloudArr) sp.add(w);
            let local: string[] = [];
            try {
                const raw = localStorage.getItem(USER_DICT_KEY);
                local = raw ? (JSON.parse(raw) as string[]) : [];
            } catch { local = []; }
            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));
            }
            try {
                const merged = [...new Set([...local, ...cloudArr])];
                localStorage.setItem(USER_DICT_KEY, JSON.stringify(merged));
            } catch { /* */ }
        }).catch(() => { /* offline */ });
        return sp;
    })();
    return spellPromise;
}

/** Persist a word to the user dictionary. Synchronous local-cache write +
 *  in-memory nspell.add for immediate effect, plus a fire-and-forget cloud
 *  round-trip so the userdict.csv on GDrive picks it up too. */
export function addToUserDict(word: string, sp: NSpellInstance): void {
    try {
        const raw = localStorage.getItem(USER_DICT_KEY);
        const arr = raw ? (JSON.parse(raw) as string[]) : [];
        if (!arr.includes(word)) {
            arr.push(word);
            localStorage.setItem(USER_DICT_KEY, JSON.stringify(arr));
        }
    } catch { /* */ }
    sp.add(word);
    addUserDictWord(word).catch(e => console.error("[spell] addUserDictWord:", e));
}

/** Is this token spelled correctly? Wraps nspell.correct with the grammar-vs-
 *  spelling distinctions (Bob 2026-07-08):
 *    - A possessive 's (or trailing apostrophe) is grammar, not spelling —
 *      strip it before the dictionary lookup so "Daniell's" checks "Daniell".
 *  Hyphenated compounds don't reach here as one token — the tokenizers in
 *  scan()/getWordAtPoint split on hyphens so each part is checked alone. */
export function isWordCorrect(word: string, sp: NSpellInstance): boolean {
    let w = word.replace(/['’]+$/u, "");      // dogs' → dogs
    w = w.replace(/['’][sS]$/u, "");          // Daniell's → Daniell
    if (w.length < MIN_WORD_LEN) return true;
    return sp.correct(w);
}

// URL / email spans to exclude from spell-checking. A URL run continues over
// characters common in URLs (letters, digits, .-_~/:?#@$&+=%!*) and
// terminates on whitespace or other punctuation (comma, semicolon, quotes,
// brackets). Covers partially-typed URLs too (scheme:… / www.… match before
// the user finishes). Bare word.word domains ("iecc.com", "bob.ma") are
// included — autolink never converts those, so the scanner must skip them
// itself.
const URL_BODY = String.raw`[\w.\-~:/?#@$&+=%!*]`;
const SKIP_SPAN_RE = new RegExp(
    String.raw`(?:(?:[A-Za-z][A-Za-z\d.+-]{1,14}://|mailto:|tel:)${URL_BODY}+` +  // scheme'd URL
    String.raw`|www\.${URL_BODY}+` +                                   // www.…
    String.raw`|[\w.+\-]+@[\w\-]+(?:\.[\w\-]+)+` +                     // email
    String.raw`|[\w\-]+(?:\.[A-Za-z][\w\-]*)+(?:/${URL_BODY}*)?)`,     // bare domain [+path]
    "g",
);

/** Character ranges of URL/email spans in `text`, as [start, end) pairs.
 *  Trailing sentence punctuation is left OUTSIDE the span — "example.com. Next"
 *  ends the URL before the "." (the dot belongs to the sentence). */
export function findSkipRanges(text: string): Array<[number, number]> {
    if (!/[.@:]/.test(text)) return [];       // no URL/email possible — cheap out
    const ranges: Array<[number, number]> = [];
    SKIP_SPAN_RE.lastIndex = 0;
    let m: RegExpExecArray | null;
    while ((m = SKIP_SPAN_RE.exec(text))) {
        let end = m.index + m[0].length;
        while (end > m.index && /[.,;:!?]/.test(text[end - 1])) end--;
        if (end > m.index) ranges.push([m.index, end]);
    }
    return ranges;
}

/** Does [start, end) overlap any of the given skip ranges? */
export function inSkipRange(ranges: Array<[number, number]>, start: number, end: number): boolean {
    for (const [s, e] of ranges) if (start < e && end > s) return true;
    return false;
}

/** Build a suggestion list: transpositions first (the single most common
 *  English typo class — "hte"→"the"), then nspell.suggest, deduped, capped at 7. */
export function buildSuggestionList(word: string, sp: NSpellInstance): string[] {
    const transposed: string[] = [];
    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: string[] = sp.suggest(word) as string[];
    const sugs: string[] = [];
    for (const s of [...transposed, ...nspellSugs]) {
        if (!sugs.includes(s)) sugs.push(s);
        if (sugs.length >= 7) break;
    }
    return sugs;
}

export type MenuItem = {
    label: string;
    action: () => void;
    emphasized?: boolean;
    separator?: boolean;
};

/** Render a floating suggestions menu at (x,y) in the given document.
 *  Dismisses on Escape, on mousedown outside the menu, or after the user
 *  picks an item. Listeners attached to every document the user could
 *  plausibly click into so the menu can't get stuck. */
export function showSuggestionsMenu(
    parentDoc: Document,
    x: number,
    y: number,
    items: MenuItem[],
    extraDismissDocs: Document[] = [],
): void {
    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`;
    const docs: Document[] = [parentDoc, ...extraDismissDocs];
    const dismiss = (e: Event) => {
        if (e.type === "keydown" && (e as KeyboardEvent).key !== "Escape") return;
        if (e.type === "mousedown" && menu.contains(e.target as Node)) 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);
}

/** Find the word at the given client (x,y) inside `root`. Returns null if
 *  there's no word there (whitespace, link, code, etc). Works in any
 *  contenteditable in the same document — used by the Quill adapter for
 *  the right-click-on-misspelling path where there's no marker span to
 *  hang off of (Quill doesn't decorate).
 *
 *  Uses caretPositionFromPoint where available (Firefox), falls back to
 *  caretRangeFromPoint (Chromium/WebView2). Both give us the text node +
 *  offset under the click; we then expand to word boundaries. */
export function getWordAtPoint(
    root: HTMLElement,
    x: number,
    y: number,
): { word: string; node: Text; start: number; end: number } | null {
    const doc = root.ownerDocument || document;
    let node: Node | null = null;
    let offset = 0;
    const winAny = doc.defaultView as any;
    if (typeof (doc as any).caretPositionFromPoint === "function") {
        const pos = (doc as any).caretPositionFromPoint(x, y);
        if (pos) { node = pos.offsetNode; offset = pos.offset; }
    } else if (typeof (doc as any).caretRangeFromPoint === "function") {
        const range = (doc as any).caretRangeFromPoint(x, y);
        if (range) { node = range.startContainer; offset = range.startOffset; }
    }
    if (!node || node.nodeType !== Node.TEXT_NODE) return null;
    // Make sure the text node is inside `root` — caretPositionFromPoint
    // can land outside if the click is in a sibling.
    let walk: Node | null = node;
    while (walk && walk !== root) walk = walk.parentNode;
    if (!walk) return null;
    // Reject if the click is inside a skipped tag (link, code, blockquote).
    let p: Node | null = node.parentNode;
    while (p && p !== root) {
        if (p.nodeType === Node.ELEMENT_NODE && SKIP_TAGS.has((p as Element).tagName)) return null;
        p = p.parentNode;
    }
    const text = (node as Text).data;
    if (offset > text.length) offset = text.length;
    // Word boundary: letters and apostrophes. NO hyphen — hyphenated
    // compounds are checked one part at a time (Bob 2026-07-08). Same
    // tokenization as the walker in spellcheck.ts so both editors flag
    // the same tokens.
    const isWordChar = (c: string) => /[\p{L}'’]/u.test(c);
    let start = offset;
    while (start > 0 && isWordChar(text[start - 1])) start--;
    let end = offset;
    while (end < text.length && isWordChar(text[end])) end++;
    if (end - start < MIN_WORD_LEN) return null;
    const word = text.slice(start, end);
    if (!/^[\p{L}]/u.test(word)) return null; // must start with a letter
    // Clicks inside a plain-text URL or email address are not words.
    if (inSkipRange(findSkipRanges(text), start, end)) return null;
    return { word, node: node as Text, start, end };
}
