const { useState, useEffect, useRef, useMemo, useCallback } = React;

// ─── Theme tokens ──────────────────────────────────────────────────────────────
const UI_FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'PingFang SC', sans-serif";

const C = {
  bg: "#f5f5f7", surface: "#ffffff", surfaceAlt: "#ebebed",
  ink: "#1c1c1e", ink2: "#6e6e73", ink3: "#a1a1a6", line: "#e2e2e5",
  accent: "#5a5a5e", accentBg: "#ededf0", accentLn: "#c7c7cc",
  good: "#5a5a5e", goodBg: "#ededf0",
};

const HUE_RAMP = ["#48484a", "#6e6e73", "#8e8e93", "#5a5a5e", "#a1a1a6", "#3a3a3c", "#7c7c80", "#9a9a9e"];
// Starting tags for a device that has never stored its own list. Taken from the
// set actually in use in the 2026-08-24 export rather than invented: "casual"
// had been renamed to "informal" and "technical" dropped entirely, and the
// register/era/hedge tags below earn their place by usage. Order is meaningful —
// hueFor indexes into HUE_RAMP by position, so reordering recolours the stripes.
//
// Only a first run reads this: once a device has saved registers, that list wins
// (see the load effect), so editing this does not change an existing install.
const REGISTERS = ["informal", "formal", "slang", "US", "UK", "1900s", "archaic", "literary", "kinda specific"];
const hueFor = (register, list) => {
  if (!register) return C.line;
  const i = (list || REGISTERS).indexOf(register);
  return HUE_RAMP[(i < 0 ? 0 : i) % HUE_RAMP.length];
};
// An entry can carry several tags; the left-edge stripe shows the first one in
// the registers list's own order, so the colour stays stable no matter which
// order the user tapped them in.
const hueForEntry = (entry, list) => {
  const own = entry?.registers || [];
  if (own.length === 0) return C.line;
  const ordered = (list || REGISTERS).find(r => own.includes(r));
  return hueFor(ordered || own[0], list);
};

const FORMS = ["noun", "verb", "adjective", "adverb", "idiom", "phrase"];

const LANGUAGES = [
  { code: "en", label: "EN", name: "English" },
  { code: "ja", label: "日本語", name: "Japanese" },
  { code: "zh", label: "中文", name: "Chinese" },
];
const langOf = (c) => LANGUAGES.find(l => l.code === c) || LANGUAGES[LANGUAGES.length - 1];


// ─── Language auto-detection ────────────────────────────────────────────────────
// Offline/fallback: simple script check. kana → Japanese, Han → Chinese, Latin → English.
// (Used on iPhone Safari and whenever the browser detector or network is unavailable.)
function detectLangScript(text) {
  const s = (text || "").trim();
  if (!s) return null;
  if (/[\u3040-\u309F\u30A0-\u30FF]/.test(s)) return "ja";       // kana → Japanese
  if (/[\u4E00-\u9FFF\u3400-\u4DBF]/.test(s)) return "zh";       // Han (no kana) → Chinese
  if (/[a-z]/i.test(s)) return "en";
  return null;
}

// Sync detector kept for any non-async callers.

// Map BCP-47 codes from the browser detector onto our supported languages.
function mapDetected(code) {
  if (!code) return null;
  const c = code.toLowerCase();
  if (c.startsWith("ja")) return "ja";
  if (c.startsWith("zh")) return "zh";
  if (c.startsWith("en")) return "en";
  return null;   // unknown → let caller fall back
}

// Lazily create (once) the browser's on-device LanguageDetector, if supported.
let _detectorPromise = null;
function getBrowserDetector() {
  if (_detectorPromise) return _detectorPromise;
  const LD = (typeof self !== "undefined") && (self.LanguageDetector || (self.translation && self.translation.createDetector));
  if (!LD) { _detectorPromise = Promise.resolve(null); return _detectorPromise; }
  _detectorPromise = (async () => {
    try {
      if (self.LanguageDetector && self.LanguageDetector.create) {
        const avail = self.LanguageDetector.availability ? await self.LanguageDetector.availability() : "available";
        if (avail === "unavailable") return null;
        return await self.LanguageDetector.create();
      }
      if (self.translation && self.translation.createDetector) {
        return await self.translation.createDetector();
      }
    } catch { return null; }
    return null;
  })();
  return _detectorPromise;
}

// Async detection: use the on-device browser model when present, else script check.
async function detectLangAsync(text) {
  const s = (text || "").trim();
  if (!s) return null;
  try {
    const det = await getBrowserDetector();
    if (det) {
      const results = await det.detect(s);
      if (results && results.length) {
        // take the highest-confidence result we recognize
        for (const r of results) {
          const mapped = mapDetected(r.detectedLanguage);
          if (mapped && (r.confidence === undefined || r.confidence > 0.5)) return mapped;
        }
      }
    }
  } catch { /* fall through to script check */ }
  return detectLangScript(s);
}

// ─── Entry factory ───────────────────────────────────────────────────────────────
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()));
const emptyEntry = (lang = "en") => ({
  id: uid(), lang, langManual: false, isDraft: true,
  word: "", forms: [], reading: "", definition: "",
  sentences: [{ text: "", source: "" }], synonyms: "", registers: [],
  linkedIds: [], dismissedIds: [], createdAt: new Date().toISOString(),
});

// Normalize an entry's sentences to the {text, source} shape (handles old string form).
const normSentences = (s) => {
  if (!Array.isArray(s) || s.length === 0) return [{ text: "", source: "" }];
  return s.map(x => typeof x === "string" ? { text: x, source: "" } : { text: x.text || "", source: x.source || "" });
};

// Migrate any older entry shape to the current one.
const migrateEntry = (x) => {
  if (x.forms === undefined) { x.forms = x.form ? [x.form] : []; delete x.form; }
  // Tags went single-value (`register`) → multi-select (`registers`) in v31.
  // Old entries and old export files both arrive here, so fold either shape in.
  if (!Array.isArray(x.registers)) {
    x.registers = x.registers ? [String(x.registers)] : [];
  }
  if (x.register) {
    if (!x.registers.includes(x.register)) x.registers.push(x.register);
  }
  delete x.register;
  x.registers = [...new Set(x.registers.filter(r => typeof r === "string" && r.trim()))];
  x.sentences = normSentences(x.sentences);
  // fold a legacy entry-level source into the first sentence
  if (x.source) {
    if (x.sentences[0] && !x.sentences[0].source) x.sentences[0].source = x.source;
    delete x.source;
  }
  if (typeof x.word === "string") x.word = x.word.trim();
  // map removed languages onto the two we support
  if (x.lang && !LANGUAGES.some(l => l.code === x.lang)) {
    x.lang = (x.lang === "ko") ? "ja" : "en";
  }
  if (!x.id) x.id = uid();
  return x;
};

// ─── Suggestion logic (exact / ai) ──────────────────────────────────────────────
// The old heuristic "meaning" layer (word/synonym-field tokens fingerprinted and
// overlap-matched against other entries' definition prose, incl. a Porter-stem +
// CJK-run + long-prefix fallback) was removed 2026-07-24: matching a hand-picked
// associated word against arbitrary prose in an unrelated entry's definition is
// structurally noisy (e.g. "endure" in one entry's synonyms field coincidentally
// matching "enduring" inside another entry's definition text, with no real
// relation between the two words). That job now belongs entirely to the AI
// compare, which judges genuine semantic relation instead of literal overlap.
const normalize = (s) => s.toLowerCase().trim();

// ─── Default casing for the two free-text fields ──────────────────────────────
// A sentence reads as a sentence, a word list reads as a word list. iOS capitalises
// the first letter of any field it can, which was turning "ambiguous, discreet" into
// "Ambiguous, discreet"; the keyboard hints below fix that at the source, and these
// two run on BLUR as the backstop for pasted and AI-filled text.
//
// On blur, never on change: writing back a value that differs from what was typed
// makes React reset the field and the browser drops the caret at the END — that was
// F18, and the note above `synonyms` in the editor says not to reintroduce it. By
// blur time the caret is already gone, so there is nothing to disturb.
const capitalizeFirst = (s) =>
  String(s).replace(/^([\s"'“‘([]*)(\p{Ll})/u, (_, lead, c) => lead + c.toUpperCase());

// Lowercases the first letter of each comma-separated term. The trailing-lowercase
// guard is what keeps acronyms intact: "NATO" and "AI" have no lowercase second
// letter, so they are left alone. A genuine proper noun ("Mercury") does get
// lowercased — the trade-off is deliberate, since every reader of `synonyms`
// lowercases anyway and vocabulary lists are overwhelmingly common nouns.
const lowercaseTermInitials = (s) =>
  String(s).split(/([,、，;；/\n]+)/)
    .map(part => part.replace(/^(\s*)(\p{Lu})(?=\p{Ll})/u, (_, sp, c) => sp + c.toLowerCase()))
    .join("");

const tokensOf = (entry) => {
  const raw = [entry.word, ...(entry.synonyms || "").split(/[,、，;；/]+/)];
  return [...new Set(raw.map(normalize).filter(t => t.length > 0))];
};

// ─── Renaming a word inside other entries' associated words ───────────────────
// Associated words are stored as free text, so a link to another entry is really
// just that entry's name spelled out. Rename the entry and every one of those
// spellings goes stale, with nothing to flag it — the user hit this after
// renaming "drive someone up the wall" to "drive one up the wall" and had to
// hunt down each referring entry by hand (v43 feedback #3).
//
// Whole terms only, never substrings: the field is a delimited list, so a term
// counts as a reference when the ENTIRE term equals the old name. That is what
// keeps a rename of "wall" from mangling "drive one up the wall". Delimiters and
// the user's own spacing are preserved by splitting with a capturing group and
// rebuilding the string verbatim around the parts that changed.
function renameTermInField(text, from, to) {
  if (!text) return { text, changed: false };
  let changed = false;
  // Splitting on a capturing group interleaves terms and separators, so the odd
  // indices are the delimiters and are copied through untouched.
  const next = text.split(/([,、，;；/\n]+)/).map((part, i) => {
    if (i % 2 === 1) return part;
    const m = part.match(/^(\s*)([\s\S]*?)(\s*)$/);
    if (m[2].toLowerCase() !== from) return part;
    changed = true;
    return m[1] + to + m[3];
  }).join("");
  return { text: next, changed };
}

// ─── Derivational (same-root) matching, v43 ───────────────────────────────────
// Restores a NARROW version of the derivation matching removed on 2026-07-24,
// after the user rejected two specific behaviours of the old Porter stemmer:
//
//   1. international/internal both stemmed to "intern" — a false positive the
//      user called unacceptable. Cause: Porter carries COMPOUND rules like
//      "-ational -> ate" that strip two morphemes in one step.
//   2. compunction/compunctious failed to match even though "-ious" is an
//      ordinary suffix. Cause: Porter's plural step runs FIRST and eats the "s"
//      of "-ous", so "compunctious" becomes "compunctiou" and the "-ous" rule
//      can never fire.
//
// Both fall out of Porter being built for search-engine recall (strip hard,
// over-merge on purpose) rather than for "are these two words the same root".
// So this is not Porter with patches — it is a different, deliberately timid
// rule: strip ONE known suffix layer off each side, then demand the remaining
// roots be IDENTICAL. One layer is what keeps international ("internation")
// off internal ("intern").
//
// -er/-or/-ar are deliberately ABSENT. They cannot be made safe by spelling
// alone: moth/mother, corn/corner, numb/number and butt/butter have exactly the
// same shape as sing/singer and read/reader. Dropping them costs the agent-noun
// pairs (rarely both recorded in a vocabulary notebook) and buys silence on a
// whole family of stupid-looking matches.
//
// Measured on a 65-assertion fixture plus a full sweep of the real 488-entry
// word bank: catches regret/regretful, compunction/compunctious, irony/ironic,
// complete/completion, absolute/absolutely, certain/certainty, anxious/anxiety;
// rejects international/internal, headstrong/strong, car/care, batter/better,
// moral/morale, mean/meant, conceal/concern, contrite/contrive.
//
// Known remaining misses, all irregular Latin stem changes that no suffix table
// can reach: deceit/deceive, conviction/convince, pretend/pretense,
// restrain/restrict, sarcasm/sarcastic, accuracy/accurate. Those stay the AI
// layer's job. Known false positives, measured at ~2% of real suggestions:
// part/party, hospital/hospitable, mention/mental, unstated/unstable. These are
// only SUGGESTIONS — nothing is linked until the user taps ✓ — and tightening
// further starts cutting real hits.
const DERIV_SUFFIXES = [
  "ness", "ment", "ship", "hood", "less", "ful", "able", "ible",
  "ance", "ence", "ant", "ent", "ious", "eous", "uous", "ous", "ive",
  "ary", "ical", "ial", "ual", "al", "ic", "ish", "ist", "ism", "ity",
  "ify", "ise", "ize", "ion", "ate", "ly", "ty", "y", "s", "es", "ed", "ing",
  // "-ation" is two morphemes (-ate + -ion) and so breaks the one-layer rule,
  // but it is the one compound worth an exception: adore/adoration and
  // admire/admiration drop the stem's final "e", which no single layer recovers.
  // It is safe here only because DERIV_MIN_ROOT rejects the words that would
  // otherwise collapse onto a common stub — station -> "st", nation -> "n".
  "ation",
  // Same exception, same reason: "-ally" is -al + -ly, and adverbs built on an
  // -ic adjective (emphatic/emphatically, basic/basically) need both layers gone
  // at once. MIN_ROOT again absorbs the damage — really -> "re", finally ->
  // "fin", totally -> "tot" are all too short to match anything.
  "ally",
  // The last group of exceptions: a derivational suffix (-ize/-ate/-ist) with a
  // verb inflection stacked on top. Without these, criticism reaches "critic"
  // but criticizing stops at "criticiz", and irritable reaches "irrit" but
  // irritated stops at "irritat" — so the two halves of a pair never meet.
  // Measured cost on the real word bank: +30 correct suggestions, +1 false
  // positive (unstated/unstable). MIN_ROOT again does the heavy lifting:
  // created -> "cre", related -> "rel", stated -> "st" are all too short.
  "izing", "ized", "ating", "ated", "istic",
].sort((a, b) => b.length - a.length);   // longest first: "-iousness" beats "-ness"
const DERIV_MIN_ROOT = 4;                // shorter roots collide by chance (car/care)
const isLatinWord = (s) => /^[a-z'\-]+$/.test(s);

// Undo the spelling changes English makes when a suffix is attached, so the two
// sides can meet on one form: adore+ation leaves "ador", happy+ness leaves
// "happi", and a doubled consonant (regret+ed -> "regrett") has to be halved.
function rootVariants(root) {
  const out = new Set([root]);
  out.add(root + "e");
  if (root.endsWith("i")) out.add(root.slice(0, -1) + "y");
  if (root.length >= 3 && root[root.length - 1] === root[root.length - 2]) out.add(root.slice(0, -1));
  return out;
}
// Every form this word could have been built from, including the word itself
// (so "regret" matches "regretful" without needing a suffix on both sides).
function derivRoots(word) {
  const out = new Set([word]);
  for (const suf of DERIV_SUFFIXES) {
    if (!word.endsWith(suf)) continue;
    const r = word.slice(0, -suf.length);
    if (r.length < DERIV_MIN_ROOT) continue;
    for (const v of rootVariants(r)) out.add(v);
  }
  return out;
}
// English-only on purpose: the suffix table is meaningless for CJK, where a
// shared character is a far weaker signal than a shared Latin root.
function sameRoot(a, b) {
  if (a === b) return false;                       // that's `exact`, handled above
  if (!isLatinWord(a) || !isLatinWord(b)) return false;
  const rb = derivRoots(b);
  for (const r of derivRoots(a)) if (r.length >= DERIV_MIN_ROOT && rb.has(r)) return true;
  return false;
}

function tokenMatch(a, b) {
  if (a === b) return "exact";
  return sameRoot(a, b) ? "root" : null;
}
function suggestionsFor(entry, allEntries) {
  const myTokens = tokensOf(entry);
  const out = [];
  for (const other of allEntries) {
    if (other.id === entry.id) continue;
    if (entry.dismissedIds?.includes(other.id)) continue;
    const otherTokens = tokensOf(other);
    // An exact hit anywhere in the entry outranks a root hit anywhere, so both
    // are collected before deciding which one to show.
    let exactHit = null, rootHit = null;
    for (const t of myTokens) {
      for (const o of otherTokens) {
        const m = tokenMatch(t, o);
        if (m === "exact") { exactHit = { mine: t, theirs: o }; break; }
        if (m === "root" && !rootHit) rootHit = { mine: t, theirs: o };
      }
      if (exactHit) break;
    }
    if (exactHit) out.push({ entry: other, term: exactHit.theirs, via: exactHit.mine, kind: "exact" });
    else if (rootHit) out.push({ entry: other, term: rootHit.theirs, via: rootHit.mine, kind: "root" });
  }
  const rank = { exact: 0, root: 1, ai: 2 };
  out.sort((a, b) => rank[a.kind] - rank[b.kind]);
  return out;
}

// ─── Search query ───────────────────────────────────────────────────────────────
// Operators are evaluated strictly LEFT TO RIGHT with no precedence — each operator
// refines the running result (like adding a bracket each step):
//   "verb / noun, adjective" → ((verb OR noun) AND adjective)
//   "verb, noun / adjective" → ((verb AND noun) OR adjective)
// A term matches a form/tag EXACTLY (so "verb" ≠ "adverb"), but still substring-
// matches the text fields (word, reading, definition, synonyms, sentences).
function termMatches(e, term, registers) {
  const t = term.trim().toLowerCase();
  if (!t) return true;
  // If the term IS a known form or register name, match it ONLY against tags
  // (exact) — so "verb" won't leak into definitions, and "adverb" ≠ "verb".
  const isFormName = FORMS.some(f => f.toLowerCase() === t);
  const isRegName = (registers || REGISTERS).some(r => r.toLowerCase() === t);
  if (isFormName || isRegName) {
    if ((e.forms || []).some(f => f.toLowerCase() === t)) return true;
    if ((e.registers || []).some(r => r.toLowerCase() === t)) return true;
    return false;
  }
  // Otherwise it's a free-text term: substring-match the text fields (and tags too).
  if ((e.forms || []).some(f => f.toLowerCase().includes(t))) return true;
  if ((e.registers || []).some(r => r.toLowerCase().includes(t))) return true;
  const text = [e.word, e.reading, e.definition, e.synonyms,
    ...((e.sentences || []).flatMap(s => [s && s.text, s && s.source]))]
    .filter(Boolean).join("  ").toLowerCase();
  return text.includes(t);
}
// Where a term matched, as a sort key — lower wins, ties fall back to alphabetical.
// Purely alphabetical ordering put the exact hit last (searching "sordid" gave
// lascivious → shoddy → sordid), because those two merely mention it in their
// definitions (v31 feedback #4).
const NO_RANK = 99;
function termRank(e, term, registers) {
  const t = term.trim().toLowerCase();
  if (!t) return NO_RANK;
  // Same special case as termMatches: an exact form/tag name only looks at tags.
  const isFormName = FORMS.some(f => f.toLowerCase() === t);
  const isRegName = (registers || REGISTERS).some(r => r.toLowerCase() === t);
  if (isFormName || isRegName) {
    const hit = (e.forms || []).some(f => f.toLowerCase() === t)
      || (e.registers || []).some(r => r.toLowerCase() === t);
    return hit ? 7 : NO_RANK;
  }
  // The reading is part of the entry's title, so it ranks with the word itself.
  const title = [e.word, e.reading].filter(Boolean).map(s => s.toLowerCase());
  if (title.some(s => s === t)) return 0;
  if (title.some(s => s.startsWith(t))) return 1;
  if (title.some(s => s.includes(t))) return 2;
  if ((e.synonyms || "").toLowerCase().includes(t)) return 3;
  if ((e.definition || "").toLowerCase().includes(t)) return 4;
  if ((e.sentences || []).some(s => (s && s.text || "").toLowerCase().includes(t))) return 5;
  if ((e.sentences || []).some(s => (s && s.source || "").toLowerCase().includes(t))) return 6;
  if ((e.forms || []).some(f => f.toLowerCase().includes(t))
    || (e.registers || []).some(r => r.toLowerCase().includes(t))) return 7;
  return NO_RANK;
}
// Best rank across the query's terms. Operators only decide WHICH entries match
// (matchesQuery); for ordering, the strongest hit an entry has is what counts.
function queryRank(e, query, registers) {
  const q = (query || "").trim();
  if (!q) return NO_RANK;
  const terms = q.split(/[,/]/).map(s => s.trim()).filter(Boolean);
  let best = NO_RANK;
  for (const t of terms) best = Math.min(best, termRank(e, t, registers));
  return best;
}
function matchesQuery(e, query, registers) {
  const q = (query || "").trim();
  if (!q) return true;
  const tokens = q.split(/([,/])/).map(s => s.trim()).filter(s => s !== "");
  if (tokens.length === 0) return true;
  let result = termMatches(e, tokens[0], registers);
  for (let i = 1; i < tokens.length - 1; i += 2) {
    const op = tokens[i];
    const termResult = termMatches(e, tokens[i + 1], registers);
    if (op === ",") result = result && termResult;
    else if (op === "/") result = result || termResult;
  }
  return result;
}

// ─── Dictionary lookup (real network API — works in a deployed/online app) ──────
// English: Wiktionary's own wikitext, via the MediaWiki action API. Japanese has
// no free API that returns Japanese-language definitions (only JP→English
// dictionaries exist), so it goes through the ai-define Netlify function (or a
// self-filled DeepSeek key) instead — see lookupWordJa below. Returns the full
// list of senses for the user to choose from.
//
// This used to call dictionaryapi.dev, which is itself a Wiktionary wrapper —
// but it drops the {{lb|en|...}} usage labels while parsing. That is how "shoddy"
// arrived carrying "Pretentious, sham, counterfeit" with nothing marking it as
// dated, which then fed a wrong AI match (v31 feedback #3). Wiktionary's raw
// wikitext has the labels; its REST /page/definition/ endpoint does NOT (the
// label span comes back empty), so the wikitext is the only source that works.
// Reading it means parsing wikitext here, which is the cost of this choice.
const DICTIONARY_RETRY_MS = [700, 1500];
async function fetchDictionaryWithRetry(url, fetchImpl = fetch, waitImpl = ms => new Promise(resolve => setTimeout(resolve, ms)), init = undefined) {
  const attempts = DICTIONARY_RETRY_MS.length + 1;
  for (let attempt = 0; attempt < attempts; attempt += 1) {
    const isLast = attempt === attempts - 1;
    try {
      const res = await fetchImpl(url, init);
      if (!isLast && (res.status === 500 || res.status === 503)) {
        await waitImpl(DICTIONARY_RETRY_MS[attempt]);
        continue;
      }
      return res;
    } catch (error) {
      if (!isLast) {
        await waitImpl(DICTIONARY_RETRY_MS[attempt]);
        continue;
      }
      throw new Error("网络或词典服务暂时不可用，请稍后重试");
    }
  }
}

// Part-of-speech names arrive in three different spellings — Wiktionary headings
// ("Adjective"), AI replies ("adj.", "Adjective") and the odd exclamation — and an
// exact-match check silently dropped every one of them that wasn't already a
// lowercase FORMS entry (v31 feedback #5). Anything unrecognised returns "" so
// the sense simply carries no form rather than a wrong one.
const FORM_ALIASES = {
  adj: "adjective", adv: "adverb", n: "noun", v: "verb",
  exclamation: "phrase", interjection: "phrase", proverb: "phrase",
  expression: "phrase", "prepositional phrase": "phrase",
  "noun phrase": "phrase", "verb phrase": "phrase", "adjectival phrase": "phrase",
};
const normalizeForm = (raw) => {
  const s = String(raw || "").trim().toLowerCase().replace(/\.+$/, "");
  if (!s) return "";
  return FORMS.includes(s) ? s : (FORM_ALIASES[s] || "");
};

// Turn one wikitext definition line into plain text: unwrap links, drop the
// templates that only carry markup, keep nothing we can't render.
// One template, already stripped of its braces, turned into whatever text it
// contributes. Most templates are markup and contribute nothing; the two that
// matter carry the sense's actual words.
const capFirst = (s) => { const t = String(s || "").trim(); return t ? t[0].toUpperCase() + t.slice(1) : ""; };

const expandTemplate = (inner) => {
  const parts = inner.split("|");
  const name = parts[0].trim().toLowerCase();
  const args = parts.slice(1).filter(p => !p.includes("="));
  // {{,}} is Wiktionary's escaped comma — it exists because a literal one would
  // split the enclosing template's arguments. Dropping it ate the comma in
  // "of the weather, air, etc." (v41 feedback #2).
  if (name === ",") return ",";
  // A non-gloss definition ("Used to call out someone who…") IS the definition —
  // dropping it left the sense empty and the whole entry unusable (F20).
  if (/^(?:n-?g|non[- ]gloss definition)$/.test(name)) return args.join(" ");
  // Capitalisation templates. Wiktionary writes the first word of a definition as
  // {{cap|silly}} / {{U|substance}} so the link target stays lowercase; dropping
  // them deleted the definition's first word and left it starting on a comma —
  // ", weight." for gravitas, ", especially at…" for frivolous (v41 feedback #4).
  if (/^(?:cap|u|uc|upper)$/.test(name)) return capFirst(args[0]);
  // Vernacular and taxonomic names take the name as their FIRST argument; the
  // second is a rank ("species"), not display text. Dropping them emptied the
  // parentheses in "the annual mercury or ()" (v41 feedback #3).
  if (/^(?:vern|taxfmt|taxlink|taxlink2|taxlinkwiki)$/.test(name)) return (args[0] || "").trim();
  // Language-prefixed links: {{l|en|mercury}}, {{m|la|herba}}. {{w|Page|shown}} is
  // a Wikipedia link whose optional second argument is the display text.
  if (/^(?:l|m|w|ll|link|mention)$/.test(name)) return (args[1] || args[0] || "").trim();
  return "";
};

const cleanWikitext = (s) => {
  let out = String(s)
    .replace(/<ref[^>]*>[\s\S]*?<\/ref>/gi, "")
    .replace(/<[^>]+>/g, "");
  // Innermost first, repeatedly: a non-gloss definition often wraps links that are
  // themselves templates, and a single pass would throw the outer one away whole.
  for (let i = 0; i < 8; i++) {
    const next = out.replace(/\{\{([^{}]*)\}\}/g, (_, inner) => expandTemplate(inner));
    if (next === out) break;
    out = next;
  }
  return out
    // [[target|shown]] and [[target#Section, shown]] both render as `shown`; the
    // comma form is what AI-written definitions use. A bare [[word#Section]] keeps
    // the word and drops the anchor.
    .replace(/\[\[[^\]|]*\|([^\]]*)\]\]/g, "$1")                      // [[target|shown]] → shown
    .replace(/\[\[[^\]|#]*#[^\],]*,\s*([^\]]*)\]\]/g, "$1")            // [[target#Sec, shown]] → shown
    .replace(/\[\[([^\]|#]*)#[^\]]*\]\]/g, "$1")                       // [[word#Sec]] → word
    .replace(/\[\[([^\]]*)\]\]/g, "$1")                               // [[word]] → word
    .replace(/'''?/g, "")
    .replace(/\s+/g, " ")
    // A dropped template leaves a hole behind. Left as-is the reader sees the seam:
    // an empty "()" where a taxonomic name was, or "hence , money-making" where an
    // inline label was. Closing the hole is what makes the sentence read as written.
    .replace(/[([]\s*[)\]]/g, "")
    .replace(/\s+([,;:.!?])/g, "$1")
    .replace(/([([])\s+/g, "$1")
    .replace(/\s+([)\]])/g, "$1")
    .replace(/\s+/g, " ")
    .trim();
};

// Peel a leading {{lb|en|…}} off a sense line, counting braces rather than matching
// a regex. A label's argument list can itself contain templates — "of the weather,
// air{{,}} etc." — and the old /\{\{lb\|en\|([^}]*)\}\}/ stopped at the FIRST inner
// brace, so it swallowed half the label and spilled "etc.}}" into the definition
// (v41 feedback #2). Returns the label's raw argument text and the rest of the line.
// Bookkeeping templates that render nothing but sit in front of the label
// ({{senseid|en|…}} is an anchor for cross-references). Left in place they hid the
// label from the scanner below, and the sense came out with no usage marking.
const LEADING_NOOP = /^\s*\{\{(?:senseid|anchor|rfd-sense|attention)\|[^{}]*\}\}\s*/i;

function takeLeadingLabel(rawBody) {
  let body = String(rawBody);
  let noop;
  while ((noop = body.match(LEADING_NOOP))) body = body.slice(noop[0].length);
  const open = body.match(/^\s*\{\{(?:lb|lbl|label)\s*\|\s*en\s*\|/i);
  if (!open) return { args: null, rest: body };
  const start = open[0].length;
  let depth = 1, i = start;
  while (i < body.length && depth > 0) {
    if (body.startsWith("{{", i)) { depth += 1; i += 2; }
    else if (body.startsWith("}}", i)) { depth -= 1; i += 2; }
    else i += 1;
  }
  // Unbalanced braces mean we can't tell where the label ends — leave the line whole
  // rather than cutting it at a guess.
  if (depth !== 0) return { args: null, rest: body };
  return { args: body.slice(start, i - 2), rest: body.slice(i) };
}

// Split a template's argument list on top-level pipes only, so a nested template's
// own pipes ({{l|en|x}} inside a label) don't create phantom labels.
function splitTopLevel(argText) {
  const out = [];
  let depth = 0, buf = "";
  for (let i = 0; i < argText.length; i += 1) {
    if (argText.startsWith("{{", i)) { depth += 1; buf += "{{"; i += 1; continue; }
    if (argText.startsWith("}}", i)) { depth -= 1; buf += "}}"; i += 1; continue; }
    // A label argument is prose, and prose routinely embeds a wikilink with a
    // piped display text — {{lb|en|slang|of an [[article#Noun|article]]…}}. Without
    // tracking [[ ]] too, the link's own "|" reads as another top-level argument
    // and shreds the label into fragments with unmatched brackets that cleanWikitext
    // can't repair, then splitTopLevel's caller rejoins the pieces with ", " —
    // producing exactly the "[[article#Noun, article]]" leak this was meant to
    // prevent (found via "on fleek" after the v43 F31 fix, which only cleaned the
    // definition body and missed that labels go through this same splitter).
    if (argText.startsWith("[[", i)) { depth += 1; buf += "[["; i += 1; continue; }
    if (argText.startsWith("]]", i)) { depth -= 1; buf += "]]"; i += 1; continue; }
    if (argText[i] === "|" && depth === 0) { out.push(buf); buf = ""; continue; }
    buf += argText[i];
  }
  out.push(buf);
  return out;
}

// Some Wiktionary entries are signposts rather than definitions: the whole sense
// line is one template pointing at another page ("# {{syn of|en|scrape the bottom
// of the barrel}}."). cleanWikitext drops unknown templates, so those lines used to
// survive as a lone "." — a sense with a part of speech and no meaning (F20).
// Recognising them does two things: the text becomes readable, and lookupWord can
// follow the pointer to where the real definition lives.
const FORM_OF_ALIASES = {
  "alt form": "alternative form of", "alt form of": "alternative form of",
  "altform": "alternative form of", "alt sp of": "alternative spelling of",
  "syn of": "synonym of", "ant of": "antonym of", "abbr of": "abbreviation of",
  "init of": "initialism of", "obs form of": "obsolete form of",
  "short for": "short for",
};
function parseFormOf(body) {
  // One template, alone on the line, with `en` as its first argument. A trailing
  // period is Wiktionary's own punctuation, not part of the target.
  const m = String(body || "").trim()
    .match(/^\{\{\s*([a-z][a-z' -]*?)\s*\|\s*en\s*\|\s*([^|}#]+?)\s*(?:[|#][^}]*)?\}\}\s*\.?\s*$/i);
  if (!m) return null;
  const raw = m[1].trim().toLowerCase();
  const relation = FORM_OF_ALIASES[raw] || (/(?:^| )of$/.test(raw) ? raw : "");
  const target = m[2].trim();
  return relation && target ? { relation, target } : null;
}

// Flatten a MediaWiki action=parse payload into { reading, senses }. Each sense is
// { form, label, definition } — `label` is Wiktionary's own usage marking
// ("dated", "colloquial", "of goods"), which is the whole reason we read wikitext.
const LABEL_MODIFIERS = new Set(["chiefly", "mainly", "mostly", "especially",
  "usually", "often", "sometimes", "now", "formerly", "originally", "also", "still"]);
const WIKI_POS = new Set(["noun", "verb", "adjective", "adverb", "phrase", "proverb",
  "interjection", "preposition", "conjunction", "pronoun", "determiner", "numeral",
  "particle", "prepositional phrase", "noun phrase", "verb phrase", "idiom"]);
function parseDictionaryPayload(data) {
  // Direct from Wikimedia it's {parse:{wikitext}}; through the `dict` relay it's
  // {wikitext}. The relay stays a dumb passthrough precisely so this parser is the
  // only copy — two parsers would drift.
  const wikitext = data && (data.parse ? data.parse.wikitext : data.wikitext);
  if (typeof wikitext !== "string" || !wikitext) return null;
  // Wiktionary pages hold every language that spells the word this way; take only
  // the English one, up to the next language heading.
  const start = wikitext.search(/^==\s*English\s*==\s*$/m);
  if (start === -1) return null;
  const rest = wikitext.slice(start + 1);
  const nextLang = rest.search(/^==[^=][\s\S]*?==\s*$/m);
  const english = nextLang === -1 ? rest : rest.slice(0, nextLang);

  let reading = "";
  const ipa = english.match(/\{\{IPA\|en\|([^|}]+)/);
  if (ipa) reading = ipa[1].trim();

  const senses = [];
  let form = "";
  for (const line of english.split("\n")) {
    const heading = line.match(/^=+\s*([^=]+?)\s*=+$/);
    if (heading) {
      const name = heading[1].trim().toLowerCase();
      // A non-POS heading (Etymology, Translations, Derived terms…) ends the
      // current part of speech rather than carrying it into unrelated lists.
      form = WIKI_POS.has(name) ? normalizeForm(name) : "";
      continue;
    }
    if (!form) continue;
    // "# text" is a sense; "#:" is an example and "#*" a quotation — skip those.
    if (!/^#+ /.test(line)) continue;
    let body = line.replace(/^#+ /, "");
    const labels = [];
    // Labels always lead the line: {{lb|en|dated}}, {{label|en|colloquial|dated}}.
    const taken = takeLeadingLabel(body);
    body = taken.rest;
    if (taken.args !== null) {
      for (const part of splitTopLevel(taken.args)) {
        // Label text is wikitext too — "traditionally [[postpositive]]" has to be
        // unwrapped like any other, or the brackets show up in the UI (v41 #2).
        const v = cleanWikitext(part);
        if (!v || part.includes("=") || v === "_" || v === "and") continue;
        // "chiefly|Internet slang" is one label in two pieces — Wiktionary renders
        // it "chiefly Internet slang", not as two separate markings.
        if (LABEL_MODIFIERS.has(v.toLowerCase())) { labels.push({ prefix: v }); continue; }
        const last = labels[labels.length - 1];
        if (last && last.prefix) labels[labels.length - 1] = `${last.prefix} ${v}`;
        else labels.push(v);
      }
    }
    const pointer = parseFormOf(body);
    const definition = pointer ? `${pointer.relation} ${pointer.target}` : cleanWikitext(body);
    // A sense has to carry an actual word. Punctuation-only leftovers mean the line
    // was markup we couldn't render — better no sense than an empty one.
    if (!/[a-z0-9À-￿]/i.test(definition)) continue;
    const label = labels.map(l => (typeof l === "string" ? l : l.prefix)).join(", ");
    const sense = { form, label, definition };
    if (pointer) sense.pointer = pointer;
    senses.push(sense);
  }
  return senses.length ? { reading, senses } : null;
}

const WIKTIONARY_URL = (word) =>
  `https://en.wiktionary.org/w/api.php?action=parse&page=${encodeURIComponent(word)}`
  + `&prop=wikitext&format=json&formatversion=2&origin=*`;

// Wikimedia is not reachable from mainland China without a VPN, and that is where
// this app is used — so the direct request cannot be the only way in. It stays as
// the fast path (no relay hop, no function invocation), with the `dict` Netlify
// function behind it for when it's blocked.
//
// Two rules keep the blocked case from being painful: the direct attempt gets ONE
// try with a short timeout (no backoff — a blocked host won't unblock in 700ms),
// and the first failure is remembered for the rest of the page's life, so only the
// first lookup of a VPN-less session pays the timeout at all.
const DIRECT_DICT_TIMEOUT_MS = 2000;
let directDictBlocked = false;

async function fetchWiktionaryDirect(word, fetchImpl = fetch) {
  if (directDictBlocked) return null;
  try {
    const init = (typeof AbortSignal !== "undefined" && AbortSignal.timeout)
      ? { signal: AbortSignal.timeout(DIRECT_DICT_TIMEOUT_MS) } : undefined;
    const res = await fetchImpl(WIKTIONARY_URL(word), init);
    // 404 means Wikimedia answered — direct works, the word just isn't there.
    if (res.status === 404) return res;
    if (!res.ok) return null;          // 5xx: let the relay try, but don't give up on direct
    return res;
  } catch {
    // Timed out, blocked, or offline. Assume blocked so the rest of this session
    // goes straight to the relay instead of stalling on every single lookup.
    directDictBlocked = true;
    return null;
  }
}

// English: Wiktionary first — authoritative wording, usage labels, a real IPA
// reading, multi-word entries included, and it costs no AI quota. Everything it
// can't serve falls through to the same AI relay Japanese uses:
//   · no such page — the expression genuinely isn't in Wiktionary;
//   · repeated 5xx / network failure on both the direct route and the relay.
// (v30 feedback #5 and #6.)
async function fetchDictionaryEntry(word) {
  let dictionary = null;
  let missing = false;

  const direct = await fetchWiktionaryDirect(word);
  if (direct) {
    if (direct.status === 404) missing = true;
    // A missing page can also come back as 200 with an `error` object, so status
    // alone isn't the check — parseDictionaryPayload returning null covers both.
    else dictionary = parseDictionaryPayload(await direct.json());
  }

  // Same upstream, so a confirmed "no such page" is not worth relaying.
  if (!dictionary && !missing) {
    try {
      const res = await fetchDictionaryWithRetry("/api/dict", fetch, undefined, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ word }),
      });
      if (res.ok) dictionary = parseDictionaryPayload(await res.json());
    } catch { /* network died — the caller's AI path is the fallback */ }
  }
  return dictionary;
}

// A page whose every sense is a pointer ("alternative form of X") holds no meaning
// of its own. Following it once gets the real definitions; more than one hop is
// refused, both because Wiktionary chains are at most one deep in practice and
// because a cycle would otherwise hang the lookup.
const pointerOnly = (dictionary) =>
  dictionary.senses.length && dictionary.senses.every(s => s.pointer)
    ? dictionary.senses[0].pointer : null;

async function lookupWord(word, lang, deepseekKey) {
  const w = word.trim();
  if (!w) throw new Error("no word");
  let dictionary = await fetchDictionaryEntry(w);

  const via = dictionary && pointerOnly(dictionary);
  if (via) {
    const target = await fetchDictionaryEntry(via.target);
    // The pointer is kept as a label so the entry records what it is a variant of;
    // if the target can't be reached, the bare signpost is no use and the AI path
    // below defines the whole expression instead.
    dictionary = target && !pointerOnly(target) ? {
      reading: dictionary.reading || target.reading,
      senses: target.senses.map(({ pointer, ...s }) => ({
        ...s,
        label: s.label ? `${s.label}, ${via.relation} ${via.target}` : `${via.relation} ${via.target}`,
      })),
    } : null;
  }
  if (dictionary) return { ...dictionary, source: "dictionary" };
  const ai = await lookupWordAi(w, { lang: "en", deepseekKey });
  return { reading: "", senses: ai.senses, source: "ai" };
}

// AI-generated definitions (DeepSeek). Japanese needs this because no free
// dictionary API returns Japanese-language glosses for Japanese words — Jotoba
// and Wiktionary both only return English even when asked for Japanese. English
// uses the same path as a fallback behind dictionaryapi.dev (see lookupWord).
// Goes through the ai-define relay by default (shared rate limit with ai-match),
// or directly to DeepSeek when the browser has its own key, mirroring
// findSemantic's relay/direct-key split.
//
// The prompts below are duplicated in netlify/functions/ai-define.js — that file
// runs in a different runtime and can't import from here. Change both together.
const aiDefinePrompt = (word, reading, lang) => lang === "en" ? {
  system: "You are a lexicographer writing concise English dictionary definitions. Reply with a JSON object only, no other text.",
  user: `Word or expression: "${word}"\n\nWrite concise English definitions for it (dictionary style, 1–2 sentences per sense). List distinct senses separately.\n\nFor each sense also give its part of speech in "pos", which MUST be exactly one of: noun, verb, adjective, adverb, idiom, phrase. Never put the word itself, or any inflected form of it, in "pos".\n\nReturn: {"senses": [{"pos": "...", "definition": "..."}]}\n\nSTRICT RULES:\n- Definitions in English only.\n- If the input is multi-word, define the WHOLE expression as it is actually used (idiom or phrase). Never define the individual words separately, and never fall back to defining just the head word.\n- If it is not a real word or expression (e.g. a typo), return {"senses": []}.\n- At most 5 senses.`,
} : {
  system: "あなたは国語辞典の編集者です。与えられた日本語の単語について、日本語のみで簡潔な定義を作成します。英語や他の言語を混ぜてはいけません。JSON オブジェクトのみで返答してください。",
  user: `単語: "${word}"${reading ? `\n読み: ${reading}` : ""}\n\nこの単語の意味を日本語で簡潔に説明してください（国語辞典スタイル、1つの意味につき1〜2文程度）。複数の異なる意味がある場合は分けて挙げてください。\n\n次の形式で返してください: {"senses": [{"definition": "..."}]}\n\n厳守事項:\n- definition は日本語のみ。英語訳を含めないこと。\n- 実在しない単語やタイポと判断した場合は {"senses": []} を返すこと。\n- 意味は最大5つまで。`,
};

async function lookupWordAi(word, { lang = "ja", reading = "", deepseekKey = "" } = {}) {
  const w = word.trim();
  if (!w) throw new Error("no word");
  const prompt = aiDefinePrompt(w, reading, lang);
  let res;
  if (deepseekKey) {
    res = await fetch("https://api.deepseek.com/chat/completions", {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${deepseekKey}` },
      body: JSON.stringify({
        model: "deepseek-v4-flash",
        response_format: { type: "json_object" },
        thinking: { type: "disabled" },
        messages: [
          { role: "system", content: prompt.system },
          { role: "user", content: prompt.user },
        ],
      }),
    });
  } else {
    res = await fetch("/api/ai-define", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ word: w, reading: reading || undefined, lang }),
    });
  }

  if (!res.ok) {
    if (!deepseekKey && res.status === 429) throw new Error("AI query limit reached — try again later");
    throw new Error("AI lookup is temporarily unavailable — please try again");
  }

  let senses;
  if (deepseekKey) {
    const data = await res.json();
    const text = (data.choices?.[0]?.message?.content || "").trim()
      .replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
    senses = (JSON.parse(text).senses || []);
  } else {
    senses = ((await res.json()).senses || []);
  }
  senses = senses
    .filter(s => s && typeof s.definition === "string" && s.definition.trim())
    .map(s => {
      // "pos", not "form": asked for a "form" the model answered with the word's
      // form — "noncommittal", "bite the bullet" — which normalizeForm then dropped,
      // so English AI senses arrived with no part of speech at all. Measured on real
      // calls: 5 of 6 words wrong under "form", 8 of 8 right under "pos" (F24 round
      // two — round one widened the alias table, which was never the problem).
      // `s.form` stays readable for a relay still running the older contract.
      const form = lang === "en" ? normalizeForm(s.pos ?? s.form) : "";
      // The model was trained on Wiktionary and sometimes hands back its markup
      // verbatim — "an [[article#Noun, article]] of clothing" reached the entry
      // brackets and all (v43 feedback). The dictionary path already cleans; this
      // one never did.
      const definition = cleanWikitext(s.definition);
      return form ? { form, definition } : { definition };
    })
    // Cleaning can empty a sense that was pure markup — an empty definition is
    // worse than one sense fewer.
    .filter(s => s.definition);
  if (senses.length === 0) throw new Error("no definitions");
  return { senses };
}

const lookupWordJa = (word, reading, deepseekKey) => lookupWordAi(word, { lang: "ja", reading, deepseekKey });

// ─── AI relation matching (prompt shared with netlify/functions/ai-match.js) ────
// Relations are classified onto one of five axes and scored on that axis, then
// filtered by a per-axis threshold. The meaning-side axes are held to 70 —
// that's where v30's "everything vaguely adjacent gets matched" came from. The
// pragmatic/scene axes sit at 60 because a genuine tone or situation link is
// inherently looser than synonymy and would otherwise never clear the bar.
// These thresholds are the one knob to turn if results feel off.
const AXIS_THRESHOLDS = { sense: 70, antonym: 70, morphology: 70, pragmatic: 60, scene: 60 };
const passesThreshold = (m) => {
  const floor = AXIS_THRESHOLDS[m?.axis];
  if (floor === undefined) return false;          // unclassified = unjustified
  return Number.isFinite(m.score) && m.score >= floor;
};

// Pass 2 — audit. Pass 1 alone measurably under-performs: asked to scan 40
// candidates at once it wants to produce output, and its confidence scores come
// back inflated (a real run scored "acerbic" 70 on the sense axis for "affable",
// whose meaning is the opposite). Re-asking with a reject-by-default auditor's
// framing fixes it — same model, different job. Measured against the real
// 565-entry wordbook, target "affable": pass 1 alone kept 19 (11 junk); with the
// audit, 10 (2 questionable). Auditing one candidate per call kept 8 and was not
// worth ~20x the upstream calls.
//
// Mirrored in netlify/functions/ai-match.js, where it runs inside the same
// function invocation and so costs nothing against the rate limit.
const AUDIT_SYSTEM_PROMPT = "You are a strict lexicographer auditing proposed word relations for a personal vocabulary notebook. Your default verdict is REJECT. Reply with a JSON object only, no other text.";

const auditUserPrompt = ({ target, shortlist }) => `Target: "${target.word}"${target.definition ? ` — ${target.definition}` : ""}

Below is a list of candidate relations someone proposed for this target. Audit EACH ONE INDEPENDENTLY and decide whether it survives.

Candidates (JSON array of {id, word, definition}):
${JSON.stringify(shortlist)}

Return {"verdicts": [{"id": "...", "keep": true|false, "why": "one short phrase"}]} with one entry for EVERY candidate. Write "why" in ${target.lang || "English"}, matching the target word's own language, and keep it to a few words.

REJECT (keep=false) if the only link is:
- both fall under some broad abstract category (personality traits, manner, quality, emotion, social behaviour)
- both are merely positive, or merely negative
- a loose metaphorical or physical resemblance ("both suggest smoothness / flowing / warmth")
- one word could simply be used while describing the other
- you had to write more than one clause to explain the connection

keep=true ONLY if the shared meaning states in a few words and is obviously right: near-synonyms, direct opposites, the same marked speech stance (e.g. both used to brush someone off), or one concrete shared situation.`;

// Survivors keep the auditor's own one-phrase reason — it is consistently
// tighter than pass 1's ("cold vs warm" against "Both describe a superficially
// pleasant but insincere manner"), which is also what makes the list readable.
const applyVerdicts = (matches, verdicts) => {
  const byId = new Map((verdicts || []).filter(v => v && typeof v.id === "string").map(v => [v.id, v]));
  return matches
    .filter(m => byId.get(m.id)?.keep === true)
    .map(m => {
      const why = byId.get(m.id).why;
      return { ...m, reason: (typeof why === "string" && why.trim()) ? why.trim() : m.reason };
    });
};

// Bumped whenever the matching prompt changes in a way that invalidates cached
// results (see targetSig in findSemantic).
const MATCH_PROMPT_VERSION = "v31-axes-audit";

const MATCH_SYSTEM_PROMPT = "You compare vocabulary entries for a personal dictionary app and find genuinely related words. Every relation you report must be classified onto exactly one axis and scored on that axis. Reply with a JSON object only, no other text.";

const matchUserPrompt = ({ target, candidates }) => {
  const associated = (target.associated || "").trim();
  // A definition describes what the word means; only a real sentence shows the
  // stance it is used to take. Without this the pragmatic axis produced nothing
  // at all — "panties in a twist" is defined as "to become overly upset", which
  // reads as a description of the upset person, while the example sentence
  // ("Don't get your panties in a twist.") is what reveals the phrase is itself
  // an act of dismissal. Costs no extra call: the sentences are already local.
  const examples = (target.examples || "").trim();
  const examplesBlock = examples ? `\nHow the user has actually seen it used: ${examples}` : "";
  // The user's own links are a hint about WHERE they think outward from — not a
  // set of examples to imitate and not an exclusion list. Feeding them back as
  // "good answers" would make the AI replay associations the user already made,
  // which is the opposite of the point: this feature exists to cover the
  // directions the user does NOT reach on their own.
  const associatedBlock = associated ? `\n\nWords the user has already linked to this entry by hand: ${associated}\nThese reveal the DIRECTION in which this particular user makes associations — which axis matters to them for this word (literal meaning, speech act, situation, tone). Your job is to COMPLEMENT that, not repeat it: give the surface semantic relations you are good at, AND recognise the user's preferred axis and extend along it to places the user has not reached. Do not return the listed words themselves, and never return a candidate merely because it resembles one of them.` : "";
  return `Target word: "${target.word}"\nDefinition: ${target.definition || "(none)"}${examplesBlock}${associatedBlock}\n\nCandidate entries (JSON array of {id, word, definition}):\n${JSON.stringify(candidates)}\n\nReturn {"matches": [{"id": "...", "axis": "...", "score": 0-100, "reason": "one short phrase"}]}. Write the "reason" text in ${target.lang || "English"}, matching the target word's own language.\n\nAXES — assign exactly one to each match:\n- "sense": same or nearly the same meaning; substitutable in real sentences.\n- "antonym": the direct opposite.\n- "pragmatic": both carry the same MARKED speech stance — dismissive, euphemistic, sarcastic, self-deprecating, condescending, exaggerating, falsely polite. Example: "get one's panties in a twist" and "dismissal" both perform brushing someone's feelings aside as not worth taking seriously.\n- "scene": both belong to one concrete situation or domain and together form a topic cluster (e.g. arraign / plea / docket in a courtroom).\n- "morphology": a fixed collocation, or the same root / a derived form.\n\nSCORING — 0-100, judged on the axis you chose:\n- sense: 90+ interchangeable in most contexts; 70-89 core meaning overlaps but register, intensity or collocation differs; 50-69 same semantic field but NOT interchangeable; under 50 they only share an abstract category.\n- antonym: 90+ the exact converse; 70-89 opposed along the same dimension but not the exact converse; under 70 merely different.\n- pragmatic: 90+ the two could serve the same purpose within one speaker's utterance; 60-89 the stance matches in kind but differs in specifics; under 60 merely both negative, or both informal.\n- scene: 90+ routinely co-occur in the same concrete situation; 60-89 same domain but less tightly bound; under 60 only a vague topical link.\n- morphology: 90+ same root or a fixed phrase; under 70 do not report it.\n\nHARD EXCLUSIONS — do not report these at all, whatever the score:\n- Any reason of the form "both are personality traits", "both relate to quality / character / emotion / manner", or any other broad abstract category, with no concrete shared meaning.\n- A reason that only restates one side's definition. It must name what the TARGET and that SPECIFIC candidate share.\n- "Both are informal" / "both are slang" / "both are formal" — a bare register level is never a pragmatic match.\n- A "pragmatic" or "scene" match whose reason does not name the specific stance or the specific situation.\n- When in doubt, leave it out.\n\nReturn {"matches": []} if none qualify.`;
};

// ─── IndexedDB storage (offline-first) ──────────────────────────────────────────
const DB_NAME = "wordbook", STORE = "kv";
function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => { req.result.createObjectStore(STORE); };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}
async function dbGet(key) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, "readonly").objectStore(STORE).get(key);
    tx.onsuccess = () => resolve(tx.result);
    tx.onerror = () => reject(tx.error);
  });
}
async function dbSet(key, value) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, "readwrite");
    tx.objectStore(STORE).put(value, key);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

// ─── Shared styles ───────────────────────────────────────────────────────────────
const inputStyle = {
  width: "100%", background: C.bg, border: `1px solid ${C.line}`,
  borderRadius: "8px", padding: "9px 12px", color: C.ink,
  fontSize: "16px", fontFamily: UI_FONT, outline: "none", boxSizing: "border-box",
  letterSpacing: "-0.01em", lineHeight: 1.4,
};
const textareaStyle = { ...inputStyle, resize: "vertical", display: "block", lineHeight: 1.5 };
const labelStyle = {
  fontSize: "11px", color: C.ink3, marginBottom: "6px",
  textTransform: "uppercase", letterSpacing: "0.05em",
  display: "flex", alignItems: "center", gap: "6px", fontFamily: UI_FONT, fontWeight: 600,
};
const addBtn = {
  background: "transparent", border: `1px dashed ${C.line}`, borderRadius: "8px",
  color: C.ink3, padding: "8px 12px", cursor: "pointer", fontSize: "13px",
  width: "100%", fontFamily: UI_FONT, letterSpacing: "-0.01em",
};
const iconBtn = {
  background: "transparent", border: "none", cursor: "pointer",
  fontSize: "13px", padding: "4px 6px", flexShrink: 0, fontFamily: UI_FONT,
};

// ─── Small components ──────────────────────────────────────────────────────────
// Renders a fixed subset of form buttons (for the two-row layout).
function FormRow({ entryId, forms, onPatch, items }) {
  const list = forms || [];
  const toggle = (f) => onPatch(entryId, (e) => {
    const cur = e.forms || [];
    return { ...e, forms: cur.includes(f) ? cur.filter(x => x !== f) : [...cur, f] };
  });
  return (
    <div style={{ display: "flex", gap: "4px" }}>
      {items.map(f => {
        const active = list.includes(f);
        return (
          <button key={f}
            onPointerDown={e => e.preventDefault()}
            onClick={() => toggle(f)}
            style={{ display: "flex", alignItems: "center", justifyContent: "center", lineHeight: 1,
              fontSize: "11px", padding: "3px 9px", minHeight: "24px", boxSizing: "border-box",
              borderRadius: "12px", cursor: "pointer",
              touchAction: "manipulation", fontFamily: UI_FONT, letterSpacing: "-0.01em",
              border: `1px solid ${active ? C.ink2 : C.line}`,
              background: active ? C.surfaceAlt : "transparent",
              color: active ? C.ink : C.ink3 }}>{f}</button>
        );
      })}
    </div>
  );
}

const moveItem = (arr, from, to) => { const next = arr.slice(); next.splice(to, 0, next.splice(from, 1)[0]); return next; };

// Which chip is under (x, y)? Three tiers, because the chips wrap into rows and a
// finger is never exactly on one:
//   1. inside a chip → that chip;
//   2. level with a row → the nearest chip in THAT row by x. Straight distance would
//      pick a chip from the row above when the finger runs past the end of the last
//      row, which reads as the drag jumping backwards;
//   3. between rows → nearest centre overall.
function chipIndexAt(rects, x, y) {
  let hit = null, sameRow = null, sameRowDx = Infinity, nearest = null, nearestDist = Infinity;
  rects.forEach((r, i) => {
    if (!r) return;
    const inRow = y >= r.top && y <= r.bottom;
    if (hit === null && inRow && x >= r.left && x <= r.right) hit = i;
    if (inRow) {
      const dx = Math.abs(x - (r.left + r.right) / 2);
      if (dx < sameRowDx) { sameRowDx = dx; sameRow = i; }
    }
    const cx = x - (r.left + r.right) / 2, cy = y - (r.top + r.bottom) / 2;
    const d = cx * cx + cy * cy;
    if (d < nearestDist) { nearestDist = d; nearest = i; }
  });
  if (hit !== null) return hit;
  return sameRow !== null ? sameRow : nearest;
}

// Drag-to-reorder for a wrapped row of chips.
//
// Target positions are hit-tested LIVE against the chips' current rects on every
// move. The first version accumulated a delta from the pointer-down Y plus a fixed
// row height, which broke the moment the page scrolled during the drag: the
// reference point went stale, the preview lagged the finger, and the drop landed
// somewhere other than where it was shown (v34 feedback). Live hit-testing has no
// reference point to go stale, and it handles a wrapped 2-D layout for free.
//
// `longPressMs > 0` means the chips have another meaning when tapped (in the tag
// list, tapping assigns the tag), so a drag has to be announced by holding still
// first; any movement before that is the user scrolling the page, and we get out
// of the way. `longPressMs === 0` is for modes where tapping means nothing else
// and the drag can start on the first movement.
function useChipReorder({ count, onReorder, onTap, longPressMs = 0 }) {
  const refs = useRef([]);
  const [drag, setDrag] = useState(null);      // {from, to, id} once actually dragging
  const dragging = useRef(false);              // same thing, readable synchronously
  const pending = useRef(null);                // {from, id, x, y, timer} before that

  const clearPending = () => {
    if (pending.current && pending.current.timer) clearTimeout(pending.current.timer);
    pending.current = null;
  };
  // Starting/stopping a drag has to be visible to the touchmove listener below on
  // the very next event, before React has re-rendered — hence the ref alongside
  // the state.
  const beginDrag = (next) => { dragging.current = true; setDrag(next); };
  const endDrag = () => { dragging.current = false; setDrag(null); };
  useEffect(() => clearPending, []);

  // Swallowing touchmove ourselves is the only way to stop the page scrolling once
  // a drag has begun. Flipping `touch-action` to none at that moment is too late:
  // iOS decides at touchstart whether a touch belongs to the scroller and does not
  // revisit it — which is exactly why sideways drags worked and vertical ones did
  // not (the page scrolls vertically, so horizontal travel was never claimed).
  // The listener must be native with `passive: false`; React's own onTouchMove is
  // registered passively, and preventDefault there is ignored.
  //
  // Attached through a callback ref rather than an effect: the two pickers mount at
  // different times (the arrange container doesn't exist until the mode is opened),
  // so an effect with an empty dependency list would find nothing to attach to.
  const detach = useRef(null);
  const containerRef = useCallback((el) => {
    if (detach.current) { detach.current(); detach.current = null; }
    if (!el) return;
    const swallow = (e) => { if (dragging.current && e.cancelable) e.preventDefault(); };
    el.addEventListener("touchmove", swallow, { passive: false });
    detach.current = () => el.removeEventListener("touchmove", swallow);
  }, []);

  const order = drag ? moveItem(Array.from({ length: count }, (_, i) => i), drag.from, drag.to)
    : Array.from({ length: count }, (_, i) => i);

  const chipAt = (x, y) => chipIndexAt(
    refs.current.slice(0, count).map(el => el && el.getBoundingClientRect()), x, y);

  // Belt and braces alongside the user-select rules on the chips: if a selection
  // did get started before the drag took over, drop it, otherwise the highlight
  // stays on screen for the whole drag.
  const dropSelection = () => {
    try {
      const sel = typeof window !== "undefined" && window.getSelection && window.getSelection();
      if (sel && !sel.isCollapsed) sel.removeAllRanges();
    } catch { /* not available — nothing to clean up */ }
  };

  const onPointerDown = (i, e) => {
    if (e.button !== undefined && e.button !== 0) return;
    e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId);
    if (longPressMs > 0) {
      const timer = setTimeout(() => {
        if (pending.current) pending.current.timer = null;
        dropSelection();
        beginDrag({ from: i, to: i, id: e.pointerId });
      }, longPressMs);
      pending.current = { from: i, id: e.pointerId, x: e.clientX, y: e.clientY, timer };
    } else {
      pending.current = { from: i, id: e.pointerId, x: e.clientX, y: e.clientY, timer: null, armed: true };
    }
  };

  const onPointerMove = (e) => {
    const p = pending.current;
    if (p && p.id === e.pointerId) {
      const moved = Math.abs(e.clientX - p.x) + Math.abs(e.clientY - p.y) > 8;
      // Moved before the hold completed → the user is scrolling, not dragging.
      if (p.timer && moved) { clearPending(); return; }
      // No hold required: the first real movement is what starts the drag.
      if (p.armed && moved) { pending.current = { ...p, armed: false }; dropSelection(); beginDrag({ from: p.from, to: p.from, id: e.pointerId }); }
    }
    if (!drag || drag.id !== e.pointerId) return;
    const j = chipAt(e.clientX, e.clientY);
    if (j === null) return;
    const to = order.indexOf(j);
    if (to !== -1 && to !== drag.to) setDrag({ ...drag, to });
  };

  const onPointerUp = (i, e) => {
    const p = pending.current;
    const mine = p && p.id === e.pointerId;
    const movedFar = mine && Math.abs(e.clientX - p.x) + Math.abs(e.clientY - p.y) > 8;
    clearPending();
    // The normal ending: a drag whose preview already tracked the finger.
    if (drag && drag.id === e.pointerId && drag.to !== drag.from) {
      endDrag();
      onReorder(drag.from, drag.to);
      return;
    }
    // Released well away from where it went down, yet no preview position was ever
    // recorded. Two ways to get here: a pointer that jumped in one step (no move
    // events at all), or a move and an up delivered in the same batch, so this
    // handler still closes over `drag === null`. Either way the gesture clearly
    // asked for a move, so resolve it from the release point rather than firing the
    // tap action at a chip the user isn't pointing at. `from` must come from the
    // gesture, not from `i` — `i` is whichever chip the release landed on.
    const from = drag ? drag.from : (mine ? p.from : null);
    if (movedFar && from !== null) {
      // chipAt gives the chip's own index; onReorder wants a POSITION, and during a
      // live drag those differ by the preview permutation.
      const j = chipAt(e.clientX, e.clientY);
      const to = j === null ? -1 : order.indexOf(j);
      endDrag();
      if (to !== -1 && to !== from) onReorder(from, to);
      return;
    }
    endDrag();
    if (mine && !drag && onTap) onTap(i);
  };

  // The gesture was taken over (page scroll, phone call, second finger). Abandon it
  // rather than committing half a drag the user can't see the end of.
  const onPointerCancel = () => { clearPending(); endDrag(); };

  const handlers = (i) => ({
    onPointerDown: e => onPointerDown(i, e),
    onPointerMove,
    onPointerUp: e => onPointerUp(i, e),
    onPointerCancel,
  });
  return { order, drag, refs, containerRef, handlers, dragging: !!drag };
}

function RegisterPicker({ entry, set, registers, onEditRegisters }) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState("");
  // Every row shows an input, but only the FOCUSED row's text is held locally, and
  // only until blur/Enter. Committing per keystroke used to remount the input and
  // drop focus, which on iOS closes the keyboard after every character (F17); a
  // draft array parallel to `registers` would be the other way to avoid that, but
  // then the two can fall out of step. One index and one string can't.
  const [renamingIdx, setRenamingIdx] = useState(null);
  const [renameText, setRenameText] = useState("");

  const reorder = (from, to) => {
    setRenamingIdx(null);
    onEditRegisters("reorder", from, to);
  };
  const commitRename = (i) => {
    if (renamingIdx !== i) return;
    const from = registers[i];
    const to = renameText.trim();
    setRenamingIdx(null);
    if (!to || to === from) return;
    // Renaming onto an existing name is rejected — it used to silently create a
    // second tag with the same name (F21). Reordering is the thing that was
    // actually wanted there, and it has its own gesture on the tag row.
    if (registers.some((r, idx) => idx !== i && r.toLowerCase() === to.toLowerCase())) return;
    onEditRegisters("rename", from, to);
  };
  const addRegister = () => {
    const name = draft.trim();
    if (!name) return;
    onEditRegisters("add", name);
    setDraft("");
  };

  const own = entry.registers || [];
  const toggle = (r) => set("registers", own.includes(r) ? own.filter(x => x !== r) : [...own, r]);

  // Reordering lives on the ordinary tag row: hold a tag for 350ms and drag it.
  // The edit panel below is deliberately a plain vertical list — once dragging
  // worked on the tag row, a second draggable surface bought nothing, and stacked
  // rows are easier to rename and delete in than wrapped chips.
  const listDrag = useChipReorder({
    count: registers.length,
    onReorder: reorder,
    onTap: i => toggle(registers[i]),
    longPressMs: 350,
  });

  const chipBase = {
    fontSize: "12px", padding: "5px 12px", borderRadius: "14px", cursor: "pointer",
    fontFamily: UI_FONT, letterSpacing: "-0.01em",
    // iOS Safari ignores unprefixed `user-select`, so the plain property alone did
    // nothing there: holding a tag started a text selection (handles and all)
    // instead of a drag. The callout property suppresses the copy/lookup bubble
    // that a long press otherwise raises. Both prefixed forms are required.
    userSelect: "none", WebkitUserSelect: "none", WebkitTouchCallout: "none",
    display: "inline-flex", alignItems: "center", gap: "6px",
  };

  if (editing) {
    return (
      <div style={{ border: `1px solid ${C.line}`, borderRadius: "8px", padding: "8px", background: C.surface }}>
        <div style={{ display: "flex", flexDirection: "column", gap: "5px", marginBottom: "6px" }}>
          {registers.map((r, i) => (
            // key is the POSITION, not the name — the name is what's being edited,
            // so keying on it would remount the input on every committed rename.
            <div key={i} style={{ display: "flex", alignItems: "center", gap: "6px" }}>
              <span style={{ width: "8px", height: "8px", borderRadius: "50%", background: hueFor(r, registers), flexShrink: 0 }} />
              <input value={renamingIdx === i ? renameText : r}
                onFocus={() => { setRenamingIdx(i); setRenameText(r); }}
                onChange={e => setRenameText(e.target.value)}
                onBlur={() => commitRename(i)}
                onKeyDown={e => { if (e.key === "Enter") e.currentTarget.blur(); if (e.key === "Escape") { setRenamingIdx(null); e.currentTarget.blur(); } }}
                style={{ ...inputStyle, fontSize: "16px", padding: "4px 8px" }} />
              <button onClick={() => onEditRegisters("remove", r)} title="Remove"
                style={{ ...iconBtn, color: C.ink3, fontSize: "12px" }}>✕</button>
            </div>
          ))}
        </div>
        <div style={{ display: "flex", gap: "6px" }}>
          <input value={draft} onChange={e => setDraft(e.target.value)} placeholder=""
            onKeyDown={e => { if (e.key === "Enter") addRegister(); }}
            style={{ ...inputStyle, fontSize: "16px", padding: "4px 8px" }} />
          <button onClick={addRegister}
            style={{ ...inputStyle, width: "auto", cursor: "pointer", background: C.surfaceAlt, color: C.ink2, fontSize: "13px", padding: "4px 10px" }}>add</button>
          <button onClick={() => { setRenamingIdx(null); setEditing(false); }}
            style={{ ...inputStyle, width: "auto", cursor: "pointer", background: C.accent, color: "#fff", border: "none", fontSize: "13px", padding: "4px 12px" }}>done</button>
        </div>
      </div>
    );
  }

  // Tags are multi-select: tapping toggles this tag on/off, leaving the others
  // alone (v30 feedback #1). Holding one for 350ms picks it up to reorder instead.
  return (
    <div ref={listDrag.containerRef} style={{ display: "flex", flexWrap: "wrap", gap: "6px", alignItems: "center" }}>
      {listDrag.order.map(i => {
        const r = registers[i];
        const active = own.includes(r);
        const hue = hueFor(r, registers);
        const held = listDrag.drag && listDrag.drag.from === i;
        return (
          <span key={i} ref={el => { listDrag.refs.current[i] = el; }} {...listDrag.handlers(i)}
            style={{ ...chipBase,
              // Only while a drag is live, so an ordinary swipe over the tags still
              // scrolls the page.
              touchAction: listDrag.dragging ? "none" : "manipulation",
              border: `1px solid ${active ? hue : C.line}`,
              background: (active || held) ? C.surfaceAlt : "transparent",
              color: active ? C.ink : C.ink3,
              opacity: held ? 0.55 : 1 }}>{r}</span>
        );
      })}
      <button onClick={() => setEditing(true)} title="Edit tags"
        style={{ ...iconBtn, fontSize: "13px", color: C.ink3, border: `1px solid ${C.line}`, borderRadius: "14px", padding: "4px 9px" }}>✎</button>
    </div>
  );
}

// Seamless (border-less) text field that auto-grows to fit, so height doesn't jump.
//
// It used to render the value as plain text and swap in a <textarea> only once
// tapped, which meant the tapped point had to be translated into a character
// offset by hand. That translation failed at exactly the place it mattered most:
// tapping before the first character lands on the container element rather than
// on a text node, so it resolved to nothing and the caret fell back to the end of
// the value — editing the start of an existing value was impossible (v30 feedback
// #3, still broken after the v31 patch).
//
// Rendering the editor the whole time hands caret placement back to the browser,
// where it is right on every platform by construction. Do NOT reintroduce a
// display/editor swap here: any such swap brings the offset problem back with it.
function TapToEdit({ value, onChange, onCommit, multiline, textStyle, inputProps }) {
  const ref = useRef(null);

  const autosize = (el) => {
    if (!el) return;
    el.style.height = "auto";
    el.style.height = el.scrollHeight + "px";
  };

  // Also fires for values that changed from outside the field (a dictionary
  // lookup filling in a definition, an import), not just for typing.
  useEffect(() => { if (multiline) autosize(ref.current); }, [value, multiline]);

  const seamless = {
    width: "100%", boxSizing: "border-box", background: "transparent",
    border: "none", outline: "none", resize: "none", overflow: "hidden",
    color: C.ink, fontSize: "16px", fontFamily: UI_FONT,
    letterSpacing: "-0.01em", lineHeight: 1.5, padding: 0, margin: 0, display: "block",
    minHeight: "1.5em", ...textStyle,
  };
  const common = {
    ref, value: value || "",
    onChange: e => { onChange(e.target.value); if (multiline) autosize(e.target); },
    // Tidy-ups that would move the caret if they ran while typing (see F18) go here
    // instead, where the field has already lost focus.
    onBlur: e => {
      if (!onCommit) return;
      const next = onCommit(e.target.value);
      if (typeof next === "string" && next !== e.target.value) onChange(next);
    },
    style: seamless,
    ...inputProps,
  };
  return multiline ? <textarea {...common} rows={1} /> : <input {...common} />;
}

function FieldToggle({ open, onToggle, label, filled }) {
  return (
    <button onClick={onToggle} style={{ ...iconBtn, padding: "2px 0", color: C.ink2,
      fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.05em", fontWeight: 600,
      display: "flex", alignItems: "center", gap: "5px" }}>
      <span style={{ color: C.ink3 }}>{open ? "▾" : "▸"}</span> {label}
      {!open && filled && <span style={{ color: C.ink3, fontWeight: 400, textTransform: "none" }}>·</span>}
    </button>
  );
}

// ─── Entry editor ──────────────────────────────────────────────────────────────
function EntryEditor({ entry, allEntries, onUpdate, onPatch, onDelete, onGoto, onCollapse, onSetLang, onWordCommitted, onWordRenamed, exiting, registers, onEditRegisters, deepseekKey }) {
  const set = (f, v) => onUpdate({ ...entry, [f]: v });
  const [showDismissed, setShowDismissed] = useState(false);
  const [lookupState, setLookupState] = useState(null);
  const [lookupErr, setLookupErr] = useState("");
  const [senses, setSenses] = useState(null);           // dictionary results to pick from
  const [senseSource, setSenseSource] = useState("");   // "dictionary" | "ai" — shown on the picker
  const [semanticState, setSemanticState] = useState(null);
  const [semanticErr, setSemanticErr] = useState("");
  const [semanticProgress, setSemanticProgress] = useState({ checked: 0, total: 0 });
  const [apiSemanticIds, setApiSemanticIds] = useState([]);
  const [showDef, setShowDef] = useState(!!entry.definition?.trim());
  const [openSource, setOpenSource] = useState({});      // which sentences show their Source field
  // Word starts editable only for a brand-new draft; existing entries show text
  // until the word is tapped again (#3).
  const [wordEditing, setWordEditing] = useState(!!entry.isDraft);
  const wordRef = useRef(null);
  // The word this entry had when the user tapped it to edit. entry.word tracks
  // every keystroke, so by blur time the old name is gone from state — but it is
  // exactly what the associated-words rewrite needs to search for.
  const wordBeforeEditRef = useRef(null);
  const imeHintRef = useRef(null);  // "ja" — set when kana seen in IME composition buffer
  const [readingState, setReadingState] = useState(null); // null | "loading" | "ok" | "fail"

  const fetchReading = () => {
    const word = (entry.word || "").trim();
    if (!word || !navigator.onLine) return;
    setReadingState("loading");
    fetch("https://jotoba.de/api/search/words", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query: word, language: "English", no_english: false }),
    }).then(r => r.ok ? r.json() : null).then(data => {
      const raw = data?.words?.[0]?.reading?.furigana || "";
      if (!raw) { setReadingState("fail"); return; }
      // Walk the furigana string: inside [kanji|kana] take the kana parts;
      // outside brackets keep characters as-is (already kana).
      let kana = "", i = 0;
      while (i < raw.length) {
        if (raw[i] === "[") {
          const end = raw.indexOf("]", i);
          const pieces = raw.slice(i + 1, end).split("|");
          kana += pieces.slice(1).join("");
          i = end + 1;
        } else { kana += raw[i]; i++; }
      }
      if (!kana) { setReadingState("fail"); return; }
      set("reading", kana);
      setReadingState("ok");
      setTimeout(() => setReadingState(null), 2000);
    }).catch(() => setReadingState("fail"));
  };

  // Use native DOM listeners for composition — React's synthetic compositionupdate
  // is unreliable on iOS Safari and may not fire for intermediate kana stages.
  useEffect(() => {
    const el = wordRef.current;
    if (!el) return;
    const onComp = (e) => {
      const buf = e.data || "";
      if (/[\u3040-\u309F\u30A0-\u30FF]/.test(buf)) imeHintRef.current = "ja";
    };
    el.addEventListener("compositionupdate", onComp);
    el.addEventListener("compositionend", onComp);
    return () => {
      el.removeEventListener("compositionupdate", onComp);
      el.removeEventListener("compositionend", onComp);
    };
  }, [wordEditing]);  // re-attach when the input mounts/unmounts
  useEffect(() => {
    if (wordEditing && wordRef.current) {
      wordRef.current.focus();
      const len = wordRef.current.value.length;
      wordRef.current.setSelectionRange(len, len);
    }
  }, [wordEditing]);

  // Feature flags by language:
  const hasLookup = entry.lang === "en" || entry.lang === "ja";
  const showPron  = entry.lang === "ja";
  const hasForm   = entry.lang === "en";

  const onWordChange = (val) => onUpdate({ ...entry, word: val });
  // Commit word on blur — trim, lowercase, and carry the IME hint into state so
  // finalizeDraft can read it from entriesRef even after this component re-renders.
  const commitWord = () => {
    const trimmed = (entry.word || "").trim().toLowerCase();
    const hint = imeHintRef.current;
    imeHintRef.current = null;
    onPatch(entry.id, e => {
      const word = trimmed;
      if (!word) return { ...e, word };
      const prevWord = (e.word || "").trim().toLowerCase();
      const shouldDetect = (e.isDraft || word !== prevWord) && !e.langManual;
      let lang = e.lang;
      if (shouldDetect) {
        if (hint === "ja" || hint === "zh") lang = hint;
        else lang = detectLangScript(word) || e.lang;
      }
      const { _imeHint, ...rest } = e;
      // Mark that the word has been committed + detected, so finalizeDraft trusts
      // this language and doesn't re-detect (which could flip ja→zh offline).
      return { ...rest, word, lang, _wordCommitted: true };
    });
    setWordEditing(false);

    // A real rename of a filed entry: carry the new name into every other
    // entry's associated words that still spells out the old one.
    const before = wordBeforeEditRef.current;
    wordBeforeEditRef.current = null;
    if (!entry.isDraft && before && trimmed && before !== trimmed) {
      onWordRenamed?.(entry.id, before, trimmed);
    }

    // Typing a word you already have should take you to it right here, not after
    // you collapse the card (v43 feedback #1). onWordCommitted decides whether a
    // jump is safe — see tryJumpToExisting.
    onWordCommitted?.(entry.id);

    // When online with no IME hint, upgrade the script guess using the browser's
    // on-device detector (distinguishes Japanese kanji from Chinese). Async, fire-and-forget.
    if (trimmed && !hint && navigator.onLine) {
      detectLangAsync(trimmed).then(better => {
        if (!better) return;
        onPatch(entry.id, e => (e.langManual || (e.word || "").trim().toLowerCase() !== trimmed)
          ? e : { ...e, lang: better });
        // The language just changed, so a same-word entry in the NEW language may
        // only now be a duplicate. Re-check once the patch has landed.
        setTimeout(() => onWordCommitted?.(entry.id), 0);
      }).catch(() => {});
    }

  };

  const setSentenceText = (i, text) => {
    const s = entry.sentences.map((x, idx) => idx === i ? { ...x, text } : x);
    set("sentences", s);
  };
  const setSentenceSource = (i, source) => {
    const s = entry.sentences.map((x, idx) => idx === i ? { ...x, source } : x);
    set("sentences", s);
  };
  const addSentence = () => set("sentences", [...entry.sentences, { text: "", source: "" }]);
  const removeSentence = (i) => {
    const s = entry.sentences.filter((_, idx) => idx !== i);
    set("sentences", s.length ? s : [{ text: "", source: "" }]);
  };

  const doLookup = async () => {
    if (!entry.word.trim()) return;
    setLookupState("loading"); setLookupErr(""); setSenses(null);
    try {
      const r = entry.lang === "ja"
        ? await lookupWordJa(entry.word, entry.reading, deepseekKey)
        : await lookupWord(entry.word, entry.lang, deepseekKey);
      // Always fill pronunciation; present definitions/forms as a picker.
      onUpdate({ ...entry, reading: r.reading || entry.reading });
      setSenses(r.senses);
      setSenseSource(r.source || (entry.lang === "ja" ? "ai" : "dictionary"));
      setShowDef(true);                       // #1: reveal definition area on success
      setLookupState("ok"); setTimeout(() => setLookupState(null), 2000);
    } catch (e) { setLookupState("fail"); setLookupErr(String(e?.message || e)); }
  };

  // Apply a chosen dictionary sense to the entry.
  const [chosenSenseIdxs, setChosenSenseIdxs] = useState(new Set());
  const chooseSense = (sense, i) => {
    setChosenSenseIdxs(prev => {
      const next = new Set(prev);
      next.has(i) ? next.delete(i) : next.add(i);
      return next;
    });
  };
  const applySenses = () => {
    if (!senses || chosenSenseIdxs.size === 0) { setSenses(null); return; }
    const chosen = senses.filter((_, i) => chosenSenseIdxs.has(i));
    // Keep the usage label in the stored text. It is what tells a later reader —
    // and the AI matcher, which only ever sees this text — that a sense is dated
    // or regional rather than current everyday meaning.
    const newDef = chosen.map(s => s.label ? `(${s.label}) ${s.definition}` : s.definition).join("\n");
    const addForms = chosen.map(s => s.form).filter(Boolean);
    const forms = [...new Set([...(entry.forms || []), ...addForms])];
    onUpdate({ ...entry, definition: newDef, forms });
    setSenses(null); setChosenSenseIdxs(new Set()); setShowDef(true);
  };

  // "More by meaning": AI compare only (DeepSeek, through the relay by default or
  // directly when this browser has its own key). The old
  // offline fingerprint/"mentioned in prose" heuristic was removed 2026-07-24
  // because its broad text overlap produced unrelated suggestions.
  //
  // Results are cached on the entry itself (`entry.aiCache`, persisted like any
  // other field) so re-clicking after nothing changed costs nothing: we only ever
  // send DeepSeek the candidates that are new or edited since the last check for
  // this target word. If the target word itself changed, the whole cache for it
  // is invalid (every relation needs re-judging), so that's the one case that
  // still re-sends everyone.
  const findSemantic = async () => {
    const word = entry.word.trim().toLowerCase();
    if (!word) return;
    setSemanticState("loading"); setSemanticErr("");

    const sigOf = (e) => `${e.word || ""}${e.definition || ""}`;
    // The target's signature now also covers its associated words (they feed the
    // prompt) and the prompt version — v31's axis/threshold rewrite makes every
    // result cached under the v30 prompt stale, so bumping the version re-runs
    // them once instead of leaving the old loose matches on screen forever.
    const targetSig = `${MATCH_PROMPT_VERSION}|${sigOf(entry)}|${(entry.synonyms || "").trim()}|${(entry.sentences || []).map(s => s?.text || "").join("|")}`;
    const cache = (entry.aiCache && entry.aiCache.targetSig === targetSig)
      ? entry.aiCache
      : { targetSig, checked: {}, results: [] };

    const liveCandidates = allEntries
      .filter(o => o.id !== entry.id)
      .map(o => ({ id: o.id, word: o.word, definition: o.definition || "", sig: sigOf(o) }));
    const toCheck = liveCandidates.filter(c => cache.checked[c.id] !== c.sig);
    const toCheckIds = new Set(toCheck.map(c => c.id));
    setSemanticProgress({ checked: 0, total: toCheck.length });

    // Prune against every OTHER entry that still exists (not just non-dismissed
    // ones), so dismissing/restoring a suggestion never forces a re-check — only
    // an actually-deleted word, or a candidate we're about to re-check, drops out.
    const stillExistingIds = new Set(allEntries.filter(o => o.id !== entry.id).map(o => o.id));
    const checked = {};
    for (const [id, sig] of Object.entries(cache.checked)) {
      if (stillExistingIds.has(id) && !toCheckIds.has(id)) checked[id] = sig;
    }
    let results = cache.results.filter(r => checked[r.id] !== undefined);

    // ── AI: compare this entry against only the new/changed candidates. Relay
    // batches stay below the Function's 50-candidate/100KB limits; direct batches
    // can be larger but are still bounded to keep prompts responsive.
    // thinking:disabled cuts latency substantially with no quality loss for this
    // classification-style task (measured ~1.7s→~1.1s for a tiny candidate set,
    // ~0.7s for ~20 candidates).
    let requestError = "";
    if (toCheck.length > 0 && navigator.onLine) {
      const batchSize = deepseekKey ? 100 : 40;
      const batches = [];
      for (let i = 0; i < toCheck.length; i += batchSize) batches.push(toCheck.slice(i, i + batchSize));
      let completed = 0;

      // The target payload is the same for every batch — build it once.
      const targetLangName = LANGUAGES.find(l => l.code === entry.lang)?.name || "English";
      // Only the TARGET's associated words go out — a candidate's own links
      // would invite transitive drift ("C links X, X links the target, so C
      // matches"), which is exactly the loose reasoning we're suppressing.
      const target = {
        word: entry.word,
        definition: entry.definition || "",
        lang: targetLangName,
        associated: (entry.synonyms || "").trim(),
        examples: (entry.sentences || []).map(s => (s?.text || "").trim()).filter(Boolean).join(" / ").slice(0, 2000),
      };

      // One batch, start to finish. Returns either its matches or the message to
      // show — it never throws, so a worker can decide what to do with a failure.
      const runBatch = async (batch) => {
        try {
          const candidates = batch.map(({ id, word, definition }) => ({ id, word, definition }));
          let res;
          if (deepseekKey) {
            res = await fetch("https://api.deepseek.com/chat/completions", {
              method: "POST",
              headers: { "Content-Type": "application/json", Authorization: `Bearer ${deepseekKey}` },
              body: JSON.stringify({
                model: "deepseek-v4-flash",
                response_format: { type: "json_object" },
                thinking: { type: "disabled" },
                messages: [
                  { role: "system", content: MATCH_SYSTEM_PROMPT },
                  { role: "user", content: matchUserPrompt({ target, candidates }) },
                ],
              }),
            });
          } else {
            res = await fetch("/api/ai-match", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ target, candidates }),
            });
          }

          if (!res.ok) {
            if (!deepseekKey && res.status === 429) {
              return { error: `Checked ${completed} of ${toCheck.length} — AI query limit reached, the rest will be checked next time you click` };
            } else if (!deepseekKey && res.status === 400) {
              return { error: "AI request was rejected — please report this" };
            }
            return { error: "AI matching is temporarily unavailable — please try again" };
          }

          let parsed;
          if (deepseekKey) {
            const data = await res.json();
            const text = (data.choices?.[0]?.message?.content || "").trim()
              .replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
            parsed = JSON.parse(text);
          } else {
            parsed = await res.json();
          }
          const batchIds = new Set(batch.map(c => c.id));
          const seenIds = new Set();
          let batchMatches = (parsed.matches || [])
            .filter(m => m?.id && batchIds.has(m.id))
            // Same id twice in one reply. Mirrors the relay's dedupe in
            // netlify/functions/ai-match.js — without it the duplicate lands in
            // entry.aiCache and never leaves (v43 feedback: "billow" twice).
            .filter(m => !seenIds.has(m.id) && seenIds.add(m.id))
            // Relay responses arrive already threshold-filtered and audited;
            // direct-key responses come straight from DeepSeek, so both the
            // threshold and the audit below happen here instead.
            .filter(m => !deepseekKey || passesThreshold(m))
            .map(m => ({ id: m.id, reason: m.reason || "related" }));

          if (deepseekKey && batchMatches.length > 0) {
            try {
              const byId = new Map(candidates.map(c => [c.id, c]));
              const shortlist = batchMatches.map(m => ({ id: m.id, word: byId.get(m.id).word, definition: byId.get(m.id).definition || "" }));
              const auditRes = await fetch("https://api.deepseek.com/chat/completions", {
                method: "POST",
                headers: { "Content-Type": "application/json", Authorization: `Bearer ${deepseekKey}` },
                body: JSON.stringify({
                  model: "deepseek-v4-flash",
                  response_format: { type: "json_object" },
                  thinking: { type: "disabled" },
                  messages: [
                    { role: "system", content: AUDIT_SYSTEM_PROMPT },
                    { role: "user", content: auditUserPrompt({ target, shortlist }) },
                  ],
                }),
              });
              if (auditRes.ok) {
                const auditData = await auditRes.json();
                const auditText = (auditData.choices?.[0]?.message?.content || "").trim()
                  .replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
                batchMatches = applyVerdicts(batchMatches, JSON.parse(auditText).verdicts);
              }
              // A failed audit keeps the unaudited matches — a looser list beats
              // an empty one, and it's the same upstream that just succeeded.
            } catch { /* keep batchMatches */ }
          }
          return { matches: batchMatches };
        } catch {
          return { error: "AI matching is temporarily unavailable — please try again" };
        }
      };

      // Batches used to run one after another, each waiting on the previous
      // round-trip (and, for a direct key, on its audit call too). With ~600
      // entries that is six serial pairs of requests — the ten-plus seconds the
      // user measured. No batch depends on another batch's result, so
      // they can overlap; a small worker pool bounds how many are in flight.
      //
      // Direct-key only. The relay path stays serial on purpose: its 429 handling
      // reports how far it got before the limit, which only means something if
      // the batches were attempted in order, and firing several at once would hit
      // that limit sooner.
      //
      // 8 rather than 4 so a normal-sized bank finishes in ONE wave: 600 entries
      // is six batches, and at a pool of 4 the last two sat waiting for a free
      // slot, costing a whole extra round-trip of wall time. Verified by the
      // batches' dispatch timestamps: pool 4 sent four immediately and the rest
      // only after the first returned; pool 8 sends all six at once. The pool
      // still exists to bound a very large bank.
      const CONCURRENCY = deepseekKey ? 8 : 1;
      let cursor = 0, stopped = false;

      const worker = async () => {
        while (!stopped) {
          const i = cursor++;
          if (i >= batches.length) return;
          const batch = batches[i];
          const r = await runBatch(batch);
          if (r.error) {
            // First failure wins and the pool winds down. In-flight batches are
            // still allowed to finish and persist below.
            if (!requestError) requestError = r.error;
            stopped = true;
            return;
          }
          // Accumulating here rather than inside runBatch keeps the shared
          // arrays single-threaded: JS interleaves only at await points, and
          // there is none between here and the end of the iteration.
          results.push(...r.matches);
          for (const c of batch) checked[c.id] = c.sig;
          completed += batch.length;
          setSemanticProgress({ checked: completed, total: toCheck.length });
          // Persist every successful batch immediately, so a later failure still
          // leaves this work cached for the next click. Functional patch, not
          // onUpdate({...entry}) — with batches overlapping, the captured `entry`
          // is a stale snapshot and concurrent writes would clobber each other.
          //
          // Writing once per batch is cheap: a longtask trace of a full 600-entry
          // run recorded no task over 50ms at all, so there is nothing here worth
          // coalescing away.
          onPatch(entry.id, e => ({ ...e, aiCache: { targetSig, checked: { ...checked }, results: [...results] } }));
        }
      };
      await Promise.all(Array.from({ length: Math.min(CONCURRENCY, batches.length) }, worker));
    }

    // Also persists pruning when there was nothing new to query, or before the
    // first batch when offline. Successful batches have already been saved above.
    onPatch(entry.id, e => ({ ...e, aiCache: { targetSig, checked, results } }));

    const merged = results.map(({ id, reason }) => ({ id, kind: "ai", term: reason }));
    setApiSemanticIds(merged);
    if (merged.length === 0) {
      setSemanticState(requestError.startsWith("Checked ") ? "ok" : "fail");
      setSemanticErr(requestError || (navigator.onLine
        ? "No related words found yet."
        : "offline — AI matching needs an internet connection"));
    } else {
      setSemanticState("ok");
      setSemanticErr(requestError);
      setTimeout(() => setSemanticState(null), 1500);
    }
  };

  const suggestions = useMemo(() => {
    const base = suggestionsFor(entry, allEntries);
    const haveIds = new Set(base.map(s => s.entry.id));
    const seenApi = new Set();
    const apiOnes = apiSemanticIds
      .map(({ id, kind, term }) => ({ e: allEntries.find(e => e.id === id), kind, term }))
      .filter(({ e }) => e && e.id !== entry.id && !(entry.dismissedIds || []).includes(e.id) && !haveIds.has(e.id))
      // Last line of defence, and the only one that helps an entry whose cache
      // was written before the two dedupes above existed — aiCache is persisted,
      // so a duplicate already stored there would otherwise show forever.
      .filter(({ e }) => !seenApi.has(e.id) && seenApi.add(e.id))
      .map(({ e, kind, term }) => ({ entry: e, term, via: null, kind }));
    return [...base, ...apiOnes];
  }, [entry, allEntries, apiSemanticIds]);



  const hue = hueForEntry(entry, registers);

  return (
    <div style={{ borderLeft: `3px solid ${hue}`, fontFamily: UI_FONT }}>

      {/* Header: tapping the word edits it; tapping blank space or ▲ collapses */}
      <div onClick={onCollapse} style={{ display: "flex", alignItems: "center", gap: "10px",
        padding: "14px 16px", background: C.surface, cursor: "pointer" }}>
        {wordEditing ? (
          <input ref={wordRef} value={entry.word} onChange={e => onWordChange(e.target.value)}
            onBlur={commitWord}
            onClick={e => e.stopPropagation()}
            placeholder=""
            style={{ flex: "0 1 auto", minWidth: 0, maxWidth: "100%", fontSize: "17px", fontWeight: 600,
              color: C.ink, fontFamily: UI_FONT, background: "transparent", border: "none",
              outline: "none", padding: 0, letterSpacing: "-0.02em", cursor: "text" }} />
        ) : (
          <span onClick={e => { e.stopPropagation(); wordBeforeEditRef.current = (entry.word || "").trim().toLowerCase(); setWordEditing(true); }}
            style={{ fontSize: "17px", fontWeight: 600, flex: "0 1 auto", minWidth: 0,
              color: entry.word ? C.ink : C.ink3, letterSpacing: "-0.02em", cursor: "text",
              overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
            {entry.word || "untitled"}
          </span>
        )}
        {entry.lang === "ja" && entry.reading && (
          <span style={{ fontSize: "13px", fontWeight: 400, color: C.ink3, letterSpacing: 0, flexShrink: 0 }}>{entry.reading}</span>
        )}
        <span style={{ flex: 1 }} />
        <button onClick={e => { e.stopPropagation(); onCollapse(); }} title="Collapse"
          style={{ background: "transparent", border: "none", cursor: "pointer", color: C.ink3,
            fontSize: "12px", flexShrink: 0, padding: 0, fontFamily: UI_FONT, lineHeight: 1 }}>▲</button>
      </div>

      <div style={{ display: "grid", gridTemplateRows: exiting ? "0fr" : "1fr",
        opacity: exiting ? 0 : 1, transition: "grid-template-rows 0.2s ease, opacity 0.2s ease" }}>
       <div style={{ overflow: "hidden", minHeight: 0 }}>
        <div style={{ padding: "4px 16px 16px", display: "flex", flexDirection: "column", gap: "11px",
          animation: exiting ? "none" : "wb-slide-down 0.2s ease" }}>

        {/* Row 1: fixed height so Definition lands at the same Y for every language (EN forms are tallest at ~46px) */}
        <div style={{ display: "flex", alignItems: "center", gap: "12px", height: "48px" }}>
          <div style={{ position: "relative", flexShrink: 0 }}>
            {/* fontSize intentionally back at 10px per explicit user decision 2026-07-31,
                overriding the project's usual ">=16px input control" rule — this reintroduces
                the risk of iOS Safari zooming in when this select is focused. See F05/F11. */}
            <select value={entry.lang} onChange={e => onSetLang(entry.id, e.target.value)} title="Change language"
              style={{ fontSize: "10px", color: C.ink2, border: `1px solid ${C.line}`, borderRadius: "5px",
                padding: "2px 4px", letterSpacing: 0, background: C.surface, fontFamily: UI_FONT,
                cursor: "pointer", outline: "none" }}>
              {LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
            </select>
            <span style={{ position: "absolute", top: "100%", left: 0, marginTop: "3px",
              fontSize: "9px", color: C.ink3, letterSpacing: "0.02em", whiteSpace: "nowrap",
              opacity: (entry.isDraft && !entry.langManual && entry.word) ? 1 : 0 }}>
              auto-detected
            </span>
          </div>
          {showPron ? (
            <div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1, minWidth: 0, marginLeft: "10px" }}>
              <input value={entry.reading} onChange={e => set("reading", e.target.value)}
                placeholder="ふりがな" style={{ ...inputStyle, fontSize: "16px" }} />
              <button onClick={fetchReading} title="Look up reading"
                style={{ background: "transparent", border: "none", cursor: "pointer", padding: "0 4px",
                  fontSize: "13px", fontFamily: UI_FONT, flexShrink: 0,
                  color: readingState === "fail" ? C.accent : readingState === "ok" ? C.good : C.ink2 }}>
                {readingState === "loading" ? "…" : readingState === "ok" ? "✓" : readingState === "fail" ? "↻" : "⤓"}
              </button>
            </div>
          ) : hasForm ? (
            <div style={{ display: "flex", flexDirection: "column", gap: "4px", marginLeft: "auto", marginRight: "28px" }}>
              <FormRow entryId={entry.id} forms={entry.forms} onPatch={onPatch}
                items={["noun", "verb", "adjective"]} />
              <FormRow entryId={entry.id} forms={entry.forms} onPatch={onPatch}
                items={["adverb", "phrase", "idiom"]} />
            </div>
          ) : null}
        </div>

      {/* Dictionary sense picker (after an English or Japanese lookup) */}
      {senses && senses.length > 0 && (
        <div style={{ background: C.accentBg, border: `1px solid ${C.accentLn}`, borderRadius: "10px", padding: "10px 12px" }}>
          <div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
            <span style={{ fontSize: "11px", color: C.ink2, textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 }}>
              Choose definitions ({senses.length})
              {senseSource === "ai" && (
                <span style={{ fontWeight: 400, textTransform: "none", letterSpacing: 0, color: C.ink3 }}> · AI</span>
              )}
            </span>
            <button onClick={applySenses}
              style={{ marginLeft: "auto", fontSize: "12px", padding: "3px 10px", borderRadius: "8px",
                background: C.accent, color: "#fff", border: "none", cursor: "pointer", fontFamily: UI_FONT,
                opacity: chosenSenseIdxs.size > 0 ? 1 : 0.4 }}>
              Done{chosenSenseIdxs.size > 0 ? ` (${chosenSenseIdxs.size})` : ""}
            </button>
            <button onClick={() => { setSenses(null); setChosenSenseIdxs(new Set()); }}
              style={{ ...iconBtn, color: C.ink3, fontSize: "12px", marginLeft: "6px" }}>✕</button>
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: "6px", maxHeight: "260px", overflowY: "auto" }}>
            {senses.map((sense, i) => {
              const chosen = chosenSenseIdxs.has(i);
              return (
                <button key={i} onClick={() => chooseSense(sense, i)}
                  style={{ textAlign: "left", background: chosen ? C.surfaceAlt : C.surface,
                    border: `1px solid ${chosen ? C.ink2 : C.line}`, borderRadius: "8px",
                    padding: "8px 10px", cursor: "pointer", fontFamily: UI_FONT, fontSize: "13px",
                    color: C.ink, lineHeight: 1.4, display: "flex", alignItems: "flex-start", gap: "8px" }}>
                  <span style={{ color: chosen ? C.ink : C.ink3, fontSize: "14px", flexShrink: 0, marginTop: "1px" }}>
                    {chosen ? "✓" : "○"}
                  </span>
                  <span>
                    {(sense.form || sense.label) && (
                      <><span style={{ color: C.ink3, fontSize: "11px", fontStyle: "italic" }}>
                        {[sense.form, sense.label].filter(Boolean).join(" · ")}
                      </span><br /></>
                    )}
                    {sense.definition}
                  </span>
                </button>
              );
            })}
          </div>
        </div>
      )}

      {/* Definition — with the dictionary lookup button (English + Japanese) */}
      <div>
        <div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
          <FieldToggle open={showDef} onToggle={() => setShowDef(v => !v)} label="Definition" filled={!!entry.definition?.trim()} />
          {hasLookup && (
            <button onClick={doLookup} title="Look up in dictionary"
              style={{ background: "transparent", border: "none", cursor: "pointer", padding: "0 4px",
                fontSize: "13px", fontFamily: UI_FONT,
                color: lookupState === "fail" ? C.accent : lookupState === "ok" ? C.good : C.ink2 }}>
              {lookupState === "loading" ? "…" : lookupState === "ok" ? "✓" : lookupState === "fail" ? "↻" : "⤓"}
            </button>
          )}
        </div>
        {lookupState === "fail" && lookupErr && (
          <div style={{ fontSize: "11px", color: C.accent, marginTop: "5px" }}>{lookupErr}</div>
        )}
        {showDef && (
          <div style={{ marginTop: "5px" }}>
            <TapToEdit value={entry.definition} onChange={v => set("definition", v)} multiline />
          </div>
        )}
      </div>

      {/* Example sentences — each with its own optional Source */}
      <div>
        <div style={labelStyle}>Example Sentences</div>
        {entry.sentences.map((s, i) => (
          <div key={i} style={{ marginBottom: "8px" }}>
            <div style={{ display: "flex", gap: "6px", alignItems: "flex-start" }}>
              <div style={{ flex: 1 }}>
                <TapToEdit value={s.text} onChange={v => setSentenceText(i, v)}
                  onCommit={capitalizeFirst} inputProps={{ autoCapitalize: "sentences" }} multiline />
              </div>
              {(entry.sentences.length > 1 || s.text) && (
                <button onClick={() => removeSentence(i)} style={{ ...iconBtn, color: C.ink3, marginTop: "2px" }}>✕</button>
              )}
            </div>
            {/* Source per sentence — only once the sentence has text */}
            {s.text.trim() && ((openSource[i] || s.source) ? (
              <input value={s.source} onChange={e => setSentenceSource(i, e.target.value)}
                placeholder="Source — link or book title"
                style={{ ...inputStyle, marginTop: "5px", marginLeft: "2px", fontSize: "16px", width: "calc(100% - 30px)" }} />
            ) : (
              <button onClick={() => setOpenSource(o => ({ ...o, [i]: true }))}
                style={{ ...iconBtn, color: C.ink3, fontSize: "11px", padding: "2px 0", marginLeft: "2px" }}>+ source</button>
            ))}
          </div>
        ))}
        {entry.sentences.every(s => s.text.trim()) && (
          <button onClick={addSentence} style={addBtn}>+ Add sentence</button>
        )}
      </div>

      {/* Associated words */}
      <div>
        <div style={labelStyle}>Associated Words</div>
        {/* No .toLowerCase() here. Writing back a value that differs from what was
            typed makes React reset the <textarea>'s value, and the browser then puts
            the caret at the END — so typing a capital letter mid-string threw the
            caret to the end on the very next keystroke. Every consumer of `synonyms`
            (tokensOf, termRank, the already-linked check) lowercases on read anyway,
            so the transform bought nothing. Do not add a transform to this onChange.
            The lowercasing the user asked for lives on onCommit (blur) instead, where
            there is no caret left to disturb — see lowercaseTermInitials. */}
        <TapToEdit value={entry.synonyms} onChange={v => set("synonyms", v)}
          onCommit={lowercaseTermInitials}
          inputProps={{ autoCapitalize: "none", autoCorrect: "off" }} multiline />
      </div>

      {/* Tags / register — always visible */}
      <div>
        <div style={labelStyle}>Tags</div>
        <RegisterPicker entry={entry} set={set} registers={registers} onEditRegisters={onEditRegisters} />
      </div>

      {/* Related words — always shown when there's a word so the button is always accessible */}
      {entry.word.trim() && (
        <div style={{ background: C.accentBg, border: `1px solid ${C.accentLn}`, borderRadius: "10px", padding: "10px 12px 9px" }}>
          <div style={{ display: "flex", alignItems: "flex-start", gap: "8px", minHeight: "22px", marginBottom: "6px" }}>
            <span style={{ display: "flex", alignItems: "center", height: "18px", lineHeight: "18px", fontSize: "11px", color: C.ink2, textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 }}>
              Related words{suggestions.length > 0 && <span style={{ color: C.ink3, fontWeight: 400 }}> ({suggestions.length})</span>}
            </span>
            <button onClick={findSemantic} title="AI matching"
              style={{ display: "flex", alignItems: "center", justifyContent: "center", lineHeight: 1,
                minHeight: "22px", boxSizing: "border-box", marginLeft: "auto", fontSize: "11px",
                padding: "3px 10px", borderRadius: "10px", cursor: "pointer", border: `1px solid ${C.accentLn}`,
                background: C.surface, color: C.ink2, fontFamily: UI_FONT, whiteSpace: "nowrap" }}>
              {semanticState === "loading"
                ? `✦ finding… ${semanticProgress.checked}/${semanticProgress.total}`
                : semanticState === "fail" ? "✦ retry" : "✦ AI matching"}
            </button>
          </div>
          {semanticErr && suggestions.length > 0
            && semanticErr !== "No related words found yet."
            && semanticErr !== "offline — AI matching needs an internet connection" && (
            <div style={{ fontSize: "11px", color: C.ink2, marginBottom: "8px", whiteSpace: "normal", overflowWrap: "anywhere" }}>
              {semanticErr}
            </div>
          )}
          {suggestions.length === 0 && (
            <div style={{ minHeight: "18px", lineHeight: "18px", fontSize: "12px", color: C.ink3,
              whiteSpace: "normal", overflowWrap: "anywhere",
              visibility: semanticState === "loading" ? "hidden" : "visible" }}>
              {semanticErr || "No related words found yet."}
            </div>
          )}
          <div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
            {suggestions.map(({ entry: s, term, via, kind }) => {
              const alreadyLinked = (entry.synonyms || "").toLowerCase().split(/[,、，;；/\n]+/).map(x => x.trim()).includes(s.word.toLowerCase());
              const addToAssociated = () => {
                const cur = (entry.synonyms || "").trim();
                const next = cur ? `${cur}, ${s.word.toLowerCase()}` : s.word.toLowerCase();
                set("synonyms", next);
              };
              return (
                <div key={s.id} style={{ display: "flex", alignItems: "flex-start", gap: "8px", fontSize: "13px" }}>
                  <button onClick={() => onGoto(s.id)} title="Go to this word"
                    style={{ display: "flex", alignItems: "flex-start", gap: "8px", flex: 1, minWidth: 0, background: "none", border: "none", cursor: "pointer", padding: "2px 0", fontFamily: UI_FONT, textAlign: "left" }}>
                    <span style={{ color: C.ink2, fontSize: "10px", border: `1px solid ${C.accentLn}`, borderRadius: "5px", padding: "1px 4px", background: C.surface, flexShrink: 0, width: "26px", textAlign: "center", display: "inline-flex", justifyContent: "center", boxSizing: "border-box" }}>{{ en: "EN", ja: "日", zh: "中" }[s.lang] || "?"}</span>
                    <span style={{ color: C.ink, flex: 1, minWidth: 0, whiteSpace: "normal", lineHeight: 1.4, overflowWrap: "anywhere" }}>
                      {s.word}<span style={{ color: C.ink3, fontSize: "11px" }}>{"  "}{kind === "exact" ? `· ${via}` : kind === "root" ? `· ~${via}` : `· ${term}`}</span>
                    </span>
                    <span style={{ color: C.ink3, fontSize: "12px", flexShrink: 0, marginTop: "2px" }}>→</span>
                  </button>
                  <button onClick={alreadyLinked ? undefined : addToAssociated}
                    title="Add to associated words"
                    style={{ ...iconBtn, color: C.ink3, fontSize: "12px", visibility: alreadyLinked ? "hidden" : "visible" }}>✓</button>
                  <button onClick={() => set("dismissedIds", [...(entry.dismissedIds || []), s.id])}
                    title="Dismiss suggestion"
                    style={{ ...iconBtn, color: C.ink3, fontSize: "12px" }}>×</button>
                </div>
              );
            })}
          </div>
          {(() => {
            const dismissed = (entry.dismissedIds || [])
              .map(id => allEntries.find(e => e.id === id)).filter(Boolean);
            if (dismissed.length === 0) return null;
            return (
              <div style={{ marginTop: "8px" }}>
                <button onClick={() => setShowDismissed(v => !v)}
                  style={{ background: "none", border: "none", cursor: "pointer", padding: "2px 0",
                    fontSize: "11px", color: C.ink3, fontFamily: UI_FONT }}>
                  {showDismissed ? "▾" : "▸"} Excluded ({dismissed.length})
                </button>
                {showDismissed && (
                  <div style={{ display: "flex", flexDirection: "column", gap: "5px", marginTop: "6px" }}>
                    {dismissed.map(s => (
                      <div key={s.id} style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px" }}>
                        <span style={{ color: C.ink3, fontSize: "10px", border: `1px solid ${C.accentLn}`, borderRadius: "5px", padding: "1px 4px", background: C.surface, flexShrink: 0, width: "26px", textAlign: "center", display: "inline-flex", justifyContent: "center", boxSizing: "border-box" }}>{{ en: "EN", ja: "日", zh: "中" }[s.lang] || "?"}</span>
                        <span style={{ color: C.ink3, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.word}</span>
                        <button onClick={() => set("dismissedIds", (entry.dismissedIds || []).filter(id => id !== s.id))}
                          title="Restore suggestion"
                          style={{ ...iconBtn, color: C.ink3, fontSize: "12px" }}>↩</button>
                      </div>
                    ))}
                  </div>
                )}
              </div>
            );
          })()}
        </div>
      )}

      <div style={{ display: "flex", alignItems: "center", marginTop: "2px" }}>
        <button onClick={onDelete} style={{ ...iconBtn, color: C.accent, fontSize: "12px" }}>Delete entry</button>
        <button onClick={onCollapse} title="Collapse entry" style={{ ...iconBtn, marginLeft: "auto", color: C.ink2, fontSize: "13px" }}>▲</button>
      </div>
        </div>
       </div>
      </div>
    </div>
  );
}

// ─── Collapsed row ────────────────────────────────────────────────────────────
function EntryRow({ entry, expanded, onToggle, highlight, showLang, registers, onSetLang }) {
  const hue = hueForEntry(entry, registers);
  return (
    <div onClick={onToggle} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "14px 16px", cursor: "pointer",
      background: highlight ? C.accentBg : expanded ? C.surfaceAlt : C.surface,
      borderLeft: `3px solid ${hue}`, transition: "background 0.15s", fontFamily: UI_FONT }}>
      <span style={{ fontSize: "17px", fontWeight: 600, color: C.ink, minWidth: 0, letterSpacing: "-0.02em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 1 auto" }}>
        {entry.word || <span style={{ color: C.ink3, fontStyle: "italic", fontWeight: 400 }}>untitled</span>}
      </span>
      {entry.lang === "ja" && entry.reading && (
        <span style={{ fontSize: "13px", fontWeight: 400, color: C.ink3, letterSpacing: 0, flexShrink: 0 }}>{entry.reading}</span>
      )}
      <span style={{ flex: 1 }} />
      {(entry.forms || []).length > 0 && (
        <span style={{ color: C.ink3, fontSize: "11px", fontStyle: "italic", flexShrink: 0,
          animation: "wb-fade-in 0.25s ease" }}>{(entry.forms || []).join(", ")}</span>
      )}
      <span style={{ color: C.ink3, fontSize: "12px", flexShrink: 0, lineHeight: 1 }}>{expanded ? "▲" : "▼"}</span>
    </div>
  );
}

// ─── Swipe-to-delete wrapper (iOS style) ────────────────────────────────────────
// Swipe a row left to reveal a red Delete button. Tap elsewhere closes it.
function SwipeRow({ onDelete, disabled, children }) {
  const [dx, setDx] = useState(0);          // current translate (negative = revealed)
  const [open, setOpen] = useState(false);
  const start = useRef(null);
  const startDx = useRef(0);
  const moved = useRef(false);
  const REVEAL = 84;                         // px width of the delete action

  const onStart = (x) => { start.current = x; startDx.current = dx; moved.current = false; };
  const onMove = (x) => {
    if (start.current == null) return;
    const delta = x - start.current;
    if (Math.abs(delta) > 6) moved.current = true;
    let next = startDx.current + delta;
    if (next > 0) next = 0;                   // don't swipe right past closed
    if (next < -REVEAL - 30) next = -REVEAL - 30;
    setDx(next);
  };
  const onEnd = () => {
    if (start.current == null) return;
    start.current = null;
    if (dx < -REVEAL / 2) { setDx(-REVEAL); setOpen(true); }
    else { setDx(0); setOpen(false); }
  };

  return (
    <div style={{ position: "relative", overflow: "hidden", borderRadius: "12px" }}>
      {/* Delete action behind the row */}
      <div style={{ position: "absolute", top: 0, right: 0, bottom: 0, width: `${REVEAL + 30}px`,
        background: "#d9534f", display: "flex", alignItems: "center", justifyContent: "flex-end" }}>
        <button onClick={() => { onDelete(); setDx(0); setOpen(false); }}
          style={{ width: `${REVEAL}px`, height: "100%", background: "transparent", border: "none",
            color: "#fff", fontSize: "14px", fontWeight: 600, cursor: "pointer", fontFamily: UI_FONT }}>
          Delete
        </button>
      </div>
      {/* Foreground row */}
      <div
        onTouchStart={disabled ? undefined : (e) => onStart(e.touches[0].clientX)}
        onTouchMove={disabled ? undefined : (e) => onMove(e.touches[0].clientX)}
        onTouchEnd={disabled ? undefined : onEnd}
        onMouseDown={disabled ? undefined : (e) => onStart(e.clientX)}
        onMouseMove={disabled ? undefined : (e) => { if (start.current != null) onMove(e.clientX); }}
        onMouseUp={disabled ? undefined : onEnd}
        onMouseLeave={disabled ? undefined : () => { if (start.current != null) onEnd(); }}
        onClickCapture={(e) => { if (moved.current || open) { e.stopPropagation(); if (open) { setDx(0); setOpen(false); } } }}
        style={{ transform: `translateX(${dx}px)`, transition: start.current == null ? "transform 0.2s ease" : "none",
          position: "relative", background: C.surface, touchAction: "pan-y" }}>
        {children}
      </div>
    </div>
  );
}

// ─── Main app ──────────────────────────────────────────────────────────────────
function App() {
  const [entries, setEntries] = useState([]);
  const [registers, setRegisters] = useState(REGISTERS);
  const [filterLang, setFilterLang] = useState("all");
  const [expandedId, setExpandedId] = useState(null);
  const [exitingId, setExitingId] = useState(null);   // entry playing its collapse animation
  const [draftId, setDraftId] = useState(null);
  const [highlightId, setHighlightId] = useState(null);
  const [search, setSearch] = useState("");
  const [loading, setLoading] = useState(true);
  const [saved, setSaved] = useState(false);
  const [menuOpen, setMenuOpen] = useState(false);
  const [keyPanelOpen, setKeyPanelOpen] = useState(false);
  const [deepseekKey, setDeepseekKey] = useState(() => localStorage.getItem("wordbank_deepseek_key") || "");
  const [keyDraft, setKeyDraft] = useState(deepseekKey);
  const saveKey = () => { const v = keyDraft.trim(); localStorage.setItem("wordbank_deepseek_key", v); setDeepseekKey(v); setKeyPanelOpen(false); };
  const clearKey = () => { localStorage.removeItem("wordbank_deepseek_key"); setDeepseekKey(""); setKeyDraft(""); };
  // Never reveal the full key once saved — only a partial mask in the current-key preview.
  const maskKey = (k) => k.length <= 8 ? "*".repeat(k.length) : `${k.slice(0, 4)}${"*".repeat(Math.max(4, k.length - 8))}${k.slice(-4)}`;
  const [reminder, setReminder] = useState(null); // {days} when an export is overdue
  const [renameNotice, setRenameNotice] = useState(null); // {from,to,ids} after a rename propagated
  const saveTimer = useRef(null);
  const rowRefs = useRef({});
  const fileInput = useRef(null);
  const headerRef = useRef(null);
  const entriesRef = useRef([]);   // always-current mirror of entries for stale-closure reads
  entriesRef.current = entries;
  const [headerH, setHeaderH] = useState(150);
  useEffect(() => {
    const measure = () => { if (headerRef.current) setHeaderH(headerRef.current.offsetHeight); };
    measure();
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, []);

  // One place that handles "bring this entry's header just under the sticky bar".
  // Only scrolls if the header isn't already roughly in place, which avoids the
  // tiny correcting scrolls that used to make entries drift on expand/collapse.
  const scrollEntryToTop = (id) => {
    requestAnimationFrame(() => {
      const el = rowRefs.current[id];
      if (!el) return;
      const top = el.getBoundingClientRect().top;
      const target = (headerRef.current?.getBoundingClientRect().bottom ?? headerH) + 8;
      if (Math.abs(top - target) > 24) window.scrollBy({ top: top - target, behavior: "smooth" });
    });
  };

  // Load
  useEffect(() => {
    (async () => {
      try {
        const e = await dbGet("entries");
        if (Array.isArray(e)) setEntries(e.map(migrateEntry));
        const r = await dbGet("registers");
        if (Array.isArray(r) && r.length) setRegisters(r);
        const last = await dbGet("lastExport");
        if (e && e.length) {
          const days = last ? Math.floor((Date.now() - last) / 86400000) : 999;
          if (days >= 7) setReminder({ days: last ? days : null });
        }
      } catch {}
      setLoading(false);
    })();
  }, []);

  // Save (debounced)
  useEffect(() => {
    if (loading) return;
    clearTimeout(saveTimer.current);
    saveTimer.current = setTimeout(async () => {
      try {
        await dbSet("entries", entries);
        await dbSet("registers", registers);
        setSaved(true); setTimeout(() => setSaved(false), 1400);
      } catch {}
    }, 500);
  }, [entries, registers, loading]);

  const addEntry = () => {
    setSearch("");
    const e = emptyEntry("en");
    setEntries(prev => [e, ...prev]);
    setExpandedId(e.id); setDraftId(e.id);
    scrollEntryToTop(e.id);     // iOS-Notes: bring the new entry up so its editor fills the screen
  };
  const updateEntry = (u) => setEntries(prev => prev.map(e => e.id === u.id ? u : e));
  // Functional patch — always operates on the LATEST entry (avoids stale-snapshot
  // clobbering when, e.g., a form is tapped right after typing in the word field).
  const patchEntry = (id, fn) => setEntries(prev => prev.map(e => e.id === id ? fn(e) : e));
  const finalizeDraft = async (u) => {
    if (!u.isDraft || !u.word.trim()) { updateEntry(u); return; }
    const word = u.word.trim().toLowerCase();
    let d;
    if (u.langManual) d = u.lang;
    else if (u._imeHint === "ja" || u._imeHint === "zh") d = u._imeHint;
    else if (u._wordCommitted) d = u.lang;   // commitWord already detected — trust it
    else d = (await detectLangAsync(word)) || u.lang;

    // #10: dedup against an existing non-draft entry with the same word+language.
    const dupe = entries.find(e => !e.isDraft && e.id !== u.id
      && e.lang === d && e.word.trim().toLowerCase() === word.toLowerCase());
    if (dupe) {
      setEntries(prev => prev.filter(e => e.id !== u.id));
      if (draftId === u.id) setDraftId(null);
      setExpandedId(dupe.id);
      setHighlightId(dupe.id);
      scrollEntryToTop(dupe.id);
      setTimeout(() => setHighlightId(null), 1600);
      return;
    }

    const filed = { ...u, word, isDraft: false, lang: d };
    delete filed._imeHint;
    delete filed._wordCommitted;
    setEntries(prev => prev.map(e => e.id === filed.id ? filed : e));
    if (draftId === filed.id) setDraftId(null);
  };
  // Called when the word field loses focus. Typing a word you already have used
  // to sit there looking like a new entry until you collapsed the card, and only
  // then jump — by which point you had often started typing a definition into
  // what was about to be thrown away. Jumping on blur closes that window.
  //
  // The guard: only a draft whose OTHER fields are all still empty may be
  // discarded this way. Once anything has been filled in, a silent jump would
  // destroy work, so those keep the old behaviour and are handled by
  // finalizeDraft on collapse (which the user reaches deliberately).
  const tryJumpToExisting = (id) => {
    const cur = entriesRef.current.find(e => e.id === id);
    if (!cur || !cur.isDraft) return;
    const word = (cur.word || "").trim().toLowerCase();
    if (!word) return;
    const untouched = !(cur.definition || "").trim()
      && !(cur.synonyms || "").trim()
      && !(cur.reading || "").trim()
      && !(cur.forms || []).length
      && !(cur.registers || []).length
      && !(cur.sentences || []).some(s => (s?.text || "").trim() || (s?.source || "").trim());
    if (!untouched) return;
    const dupe = entriesRef.current.find(e => !e.isDraft && e.id !== id
      && e.lang === cur.lang && (e.word || "").trim().toLowerCase() === word);
    if (!dupe) return;
    setEntries(prev => prev.filter(e => e.id !== id));
    setDraftId(d => d === id ? null : d);
    setExpandedId(dupe.id);
    setHighlightId(dupe.id);
    scrollEntryToTop(dupe.id);
    setTimeout(() => setHighlightId(null), 1600);
  };
  // Rename propagation. Rewrites the old name to the new one in every OTHER
  // entry's associated words, then says so — silently editing a pile of entries
  // the user is not looking at would be worse than the manual fixing it
  // replaces, so the notice names both spellings and offers an undo.
  const propagateRename = (id, from, to) => {
    // Work out which entries change BEFORE touching state. Collecting them
    // inside the setEntries updater looked equivalent but was not: React runs
    // the updater after this function returns, so the list read back here was
    // always empty and the notice never appeared.
    const touched = entriesRef.current
      .filter(e => e.id !== id && renameTermInField(e.synonyms, from, to).changed)
      .map(e => e.id);
    if (!touched.length) return;
    const idSet = new Set(touched);
    setEntries(prev => prev.map(e => idSet.has(e.id)
      ? { ...e, synonyms: renameTermInField(e.synonyms, from, to).text }
      : e));
    setRenameNotice({ from, to, ids: touched });
  };
  // Undo puts the old spelling back, but only in the entries this rename
  // actually touched — an entry the user edited by hand since then is not in
  // the list and is left alone.
  const undoRename = () => {
    if (!renameNotice) return;
    const { from, to, ids } = renameNotice;
    const idSet = new Set(ids);
    setEntries(prev => prev.map(e => {
      if (!idSet.has(e.id)) return e;
      const { text, changed } = renameTermInField(e.synonyms, to, from);
      return changed ? { ...e, synonyms: text } : e;
    }));
    setRenameNotice(null);
  };
  const deleteEntry = (id) => { setEntries(prev => prev.filter(e => e.id !== id)); if (expandedId === id) setExpandedId(null); };
  const collapseEntry = (entry) => {
    setExitingId(entry.id);
    setExpandedId(null);
    const entryId = entry.id;
    // Collapsing from the ▲ at the bottom of a long editor leaves the entry's own
    // title far above the viewport, so the row you just closed isn't on screen —
    // you have to hunt for where you were. Bring its header back under the sticky
    // bar, the same place expanding puts it.
    //
    // Started now rather than after the animation on purpose: the entry's top edge
    // does not move while it collapses (only its height shrinks, pulling the rows
    // BELOW it up), so the scroll and the collapse run over the same ~200ms and
    // read as one motion instead of a close followed by a jump.
    scrollEntryToTop(entryId);
    setTimeout(() => {
      setExitingId(null);
      const latest = entriesRef.current.find(e => e.id === entryId);
      if (!latest || !latest.isDraft) return;
      if (!latest.word.trim()) {
        setEntries(prev => prev.filter(e => e.id !== entryId));
        if (draftId === entryId) setDraftId(null);
      } else {
        finalizeDraft(latest);
      }
    }, 210);
  };
  const gotoEntry = (id) => {
    const target = entries.find(e => e.id === id); if (!target) return;
    setFilterLang(prev => (prev === "all" || prev === target.lang) ? prev : "all");
    setExpandedId(id); setHighlightId(id);
    scrollEntryToTop(id);
    setTimeout(() => setHighlightId(null), 1600);
  };
  const editRegisters = (op, a, b) => {
    if (op === "add") { const name = (a || "").trim(); if (!name) return; setRegisters(prev => prev.includes(name) ? prev : [...prev, name]); }
    else if (op === "rename") {
      const from = a, to = (b || "").trim();
      if (!to || to === from) return;
      // Renaming onto a name that already exists used to produce two identical
      // tags (v31 feedback #2). Swapping two tags' positions is what the user
      // actually wanted there — that's the "reorder" op below.
      if (registers.some(r => r !== from && r.toLowerCase() === to.toLowerCase())) return;
      setRegisters(prev => prev.map(r => r === from ? to : r));
      setEntries(prev => prev.map(e => (e.registers || []).includes(from)
        ? { ...e, registers: [...new Set((e.registers || []).map(r => r === from ? to : r))] }
        : e));
    }
    else if (op === "remove") {
      setRegisters(prev => {
        if (prev.length <= 1) return prev;
        // Multi-select: deleting a tag just drops it from every entry. (The old
        // single-value model had to reassign those entries to another tag.)
        setEntries(es => es.map(e => (e.registers || []).includes(a)
          ? { ...e, registers: (e.registers || []).filter(r => r !== a) }
          : e));
        return prev.filter(r => r !== a);
      });
    }
    else if (op === "reorder") {
      // a = from index, b = to index. Entries store tag NAMES, so only the tag
      // list itself moves; what changes for entries is which tag wins the left
      // edge stripe (hueForEntry follows this order).
      const from = a, to = b;
      setRegisters(prev => {
        if (from === to || !(from >= 0 && from < prev.length) || !(to >= 0 && to < prev.length)) return prev;
        const next = prev.slice();
        next.splice(to, 0, next.splice(from, 1)[0]);
        return next;
      });
    }
  };

  // ── Export / Import ──
  const doExport = async () => {
    const payload = { app: "WordBank", version: 1, exportedAt: new Date().toISOString(), registers, entries };
    const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    const stamp = new Date().toISOString().slice(0, 10);
    a.href = url; a.download = `wordbook-${stamp}.json`;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(url), 1000);
    await dbSet("lastExport", Date.now());
    setReminder(null); setMenuOpen(false);
  };
  const doImport = (file) => {
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const data = JSON.parse(reader.result);
        const incoming = Array.isArray(data) ? data : data.entries;
        if (!Array.isArray(incoming)) throw new Error("not a WordBank file");
        const migrated = incoming.map(migrateEntry);
        // merge by id (incoming wins), keep any local-only entries
        const byId = new Map(entries.map(e => [e.id, e]));
        for (const e of migrated) byId.set(e.id, e);
        setEntries([...byId.values()]);
        if (data.registers && Array.isArray(data.registers) && data.registers.length) {
          setRegisters(prev => [...new Set([...prev, ...data.registers])]);
        }
        setMenuOpen(false);
      } catch (err) {
        alert("Couldn't import that file: " + (err?.message || err));
      }
    };
    reader.readAsText(file);
  };

  const counts = {};
  entries.forEach(e => { counts[e.lang] = (counts[e.lang] || 0) + 1; });

  const sortedOrderRef = useRef([]);
  const visible = useMemo(() => {
    const showingAll = filterLang === "all";
    const collator = new Intl.Collator(filterLang === "ja" ? "ja" : filterLang === "ko" ? "ko" : filterLang === "zh" ? "zh" : undefined, { sensitivity: "base" });
    const list = entries
      .filter(e => showingAll || e.lang === filterLang || (e.isDraft && e.id === draftId) || e.id === expandedId)
      .filter(e => e.id === expandedId || matchesQuery(e, search, registers));

    if (expandedId) {
      // An entry is being edited — keep the previously sorted order so the list
      // doesn't reshuffle on every keystroke. A brand-new draft (not yet in the
      // sorted order) stays pinned to the TOP until it's saved.
      const order = sortedOrderRef.current;
      const rank = (id) => { const i = order.indexOf(id); return i === -1 ? -1 : i; };
      return list.slice().sort((a, b) => rank(a.id) - rank(b.id));
    }

    // Nothing expanded → sort and remember the order. With a search active the
    // primary key is WHERE the query hit (title before associated words before
    // definition before sentences); alphabetical only breaks ties.
    const searching = (search || "").trim() !== "";
    const rankOf = new Map(searching ? list.map(e => [e.id, queryRank(e, search, registers)]) : []);
    const sorted = list.slice().sort((a, b) => {
      if (searching) {
        const d = rankOf.get(a.id) - rankOf.get(b.id);
        if (d !== 0) return d;
      }
      const useReading = filterLang === "ja";
      const ka = useReading ? (a.reading || a.word) : a.word;
      const kb = useReading ? (b.reading || b.word) : b.word;
      return collator.compare(ka || "", kb || "");
    });
    sortedOrderRef.current = sorted.map(e => e.id);
    return sorted;
  }, [entries, filterLang, search, draftId, expandedId, registers]);

  return (
    <div style={{ minHeight: "100vh", background: C.bg, fontFamily: UI_FONT, color: C.ink, letterSpacing: "-0.01em", WebkitFontSmoothing: "antialiased" }}>
      <style>{`@keyframes wb-slide-down { from { opacity: 0; } to { opacity: 1; } } @keyframes wb-fade-in { from { opacity: 0; } to { opacity: 1; } }`}</style>
      <div ref={headerRef} style={{ background: C.bg, borderBottom: `1px solid ${C.line}`, padding: "16px 20px 0", position: "sticky", top: "env(safe-area-inset-top)", zIndex: 10 }}>
        <div style={{ maxWidth: "780px", margin: "0 auto" }}>
          <div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "14px" }}>
            <div style={{ display: "flex", alignItems: "baseline", gap: "8px", flexShrink: 0 }}>
              <div style={{ fontSize: "30px", fontWeight: 700, color: C.ink, letterSpacing: "-0.03em", lineHeight: 1 }}>
                Word<span style={{ color: C.accent }}>Bank</span>
              </div>
              <span style={{ fontSize: "11px", color: C.ink3, lineHeight: 1, whiteSpace: "nowrap" }}>v46</span>
              {saved && <span style={{ fontSize: "12px", color: C.ink3, whiteSpace: "nowrap" }}>saved ✓</span>}
            </div>
            <div style={{ marginLeft: "auto", display: "flex", gap: "7px", alignItems: "center", flexShrink: 0 }}>
              <div style={{ position: "relative" }}>
                <button onClick={() => setMenuOpen(v => !v)} title="Backup & restore"
                  style={{ background: C.surface, color: C.ink2, border: `1px solid ${C.line}`, borderRadius: "10px", padding: "8px 11px", fontSize: "15px", cursor: "pointer", fontFamily: UI_FONT, lineHeight: 1 }}>⋯</button>
                {menuOpen && (
                  <div style={{ position: "absolute", right: 0, top: "44px", background: C.surface, border: `1px solid ${C.line}`, borderRadius: "10px", boxShadow: "0 4px 16px rgba(0,0,0,0.1)", overflow: "hidden", zIndex: 20, minWidth: "220px" }}>
                    <button onClick={doExport} style={menuItem}>Export backup (.json)</button>
                    <button onClick={() => fileInput.current?.click()} style={menuItem}>Import backup…</button>
                    <button onClick={() => { setKeyDraft(deepseekKey); setKeyPanelOpen(v => !v); }} style={menuItem}>
                      DeepSeek API Key{deepseekKey ? " ✓" : ""}…
                    </button>
                    {keyPanelOpen && (
                      <div style={{ padding: "10px 12px", borderTop: `1px solid ${C.line}` }} onClick={e => e.stopPropagation()}>
                        {deepseekKey && (
                          <div style={{ fontSize: "11px", color: C.ink2, marginBottom: "6px", fontFamily: "monospace" }}>
                            Current: {maskKey(deepseekKey)}
                          </div>
                        )}
                        <input type="password" value={keyDraft} onChange={e => setKeyDraft(e.target.value)}
                          placeholder="sk-…" autoComplete="off"
                          style={{ ...inputStyle, width: "100%", fontSize: "16px", padding: "6px 8px", marginBottom: "6px", boxSizing: "border-box" }} />
                        <div style={{ display: "flex", gap: "6px" }}>
                          <button onClick={saveKey} style={{ flex: 1, background: C.accent, color: "#fff", border: "none", borderRadius: "8px", padding: "6px 10px", fontSize: "12px", cursor: "pointer", fontFamily: UI_FONT }}>Save</button>
                          <button onClick={clearKey} style={{ background: "none", border: `1px solid ${C.line}`, color: C.ink2, borderRadius: "8px", padding: "6px 10px", fontSize: "12px", cursor: "pointer", fontFamily: UI_FONT }}>Clear</button>
                        </div>
                        <div style={{ fontSize: "11px", color: C.ink3, marginTop: "6px" }}>
                          Stored only on this device/browser, never shown in full once saved. Uses
                          your key directly for AI matching.
                        </div>
                      </div>
                    )}
                  </div>
                )}
              </div>
              <button onClick={addEntry} style={{ background: C.accent, color: "#fff", border: "none", borderRadius: "10px", padding: "8px 14px", fontSize: "14px", fontWeight: 600, cursor: "pointer", flexShrink: 0, fontFamily: UI_FONT, letterSpacing: "-0.01em", whiteSpace: "nowrap", lineHeight: 1 }}>+ New</button>
            </div>
          </div>

          {reminder && (
            <div style={{ background: C.accentBg, border: `1px solid ${C.accentLn}`, borderRadius: "10px", padding: "9px 12px", marginBottom: "10px", display: "flex", alignItems: "center", gap: "10px", fontSize: "13px" }}>
              <span style={{ color: C.ink2 }}>
                {reminder.days ? `Last backup ${reminder.days} days ago.` : "No backup yet."} Export to keep your devices in sync.
              </span>
              <button onClick={doExport} style={{ marginLeft: "auto", background: C.accent, color: "#fff", border: "none", borderRadius: "8px", padding: "5px 12px", fontSize: "12px", cursor: "pointer", fontFamily: UI_FONT, whiteSpace: "nowrap" }}>Export now</button>
              <button onClick={() => setReminder(null)} style={{ ...iconBtn, color: C.ink3, fontSize: "12px" }}>✕</button>
            </div>
          )}

          {renameNotice && (
            <div style={{ background: C.accentBg, border: `1px solid ${C.accentLn}`, borderRadius: "10px", padding: "9px 12px", marginBottom: "10px", display: "flex", alignItems: "center", gap: "10px", fontSize: "13px" }}>
              <span style={{ color: C.ink2, flex: 1, minWidth: 0, whiteSpace: "normal", overflowWrap: "anywhere" }}>
                Renamed to “{renameNotice.to}” in the associated words of {renameNotice.ids.length} {renameNotice.ids.length === 1 ? "entry" : "entries"}.
              </span>
              <button onClick={undoRename} style={{ background: C.accent, color: "#fff", border: "none", borderRadius: "8px", padding: "5px 12px", fontSize: "12px", cursor: "pointer", fontFamily: UI_FONT, whiteSpace: "nowrap" }}>Undo</button>
              <button onClick={() => setRenameNotice(null)} style={{ ...iconBtn, color: C.ink3, fontSize: "12px" }}>✕</button>
            </div>
          )}

          <div style={{ position: "relative", marginBottom: "12px" }}>
            <input value={search} onChange={e => setSearch(e.target.value)} placeholder=""
              style={{ ...inputStyle, fontSize: "16px", background: C.surfaceAlt, border: "none", borderRadius: "10px", paddingRight: (search || filterLang !== "all") ? "36px" : "12px" }} />
            {(search || filterLang !== "all") && (
              <button onClick={() => { setSearch(""); setFilterLang("all"); }} title="Clear"
                style={{ position: "absolute", right: "10px", top: "50%", transform: "translateY(-50%)",
                  background: "transparent", border: "none", padding: "4px", lineHeight: 1,
                  fontSize: "15px", color: C.ink3, cursor: "pointer", fontFamily: UI_FONT }}>
                ✕
              </button>
            )}
          </div>

          <div style={{ display: "flex", gap: "6px", flexWrap: "wrap", alignItems: "center", paddingBottom: "14px" }}>
            {["all", ...LANGUAGES.map(l => l.code)].map(code => {
              const active = filterLang === code;
              const label = code === "all" ? "All" : langOf(code).label;
              const n = code === "all" ? entries.length : (counts[code] || 0);
              if (code !== "all" && n === 0) return null;
              return (
                <button key={code} onClick={() => setFilterLang(code)}
                  style={{ fontSize: "12px", padding: "5px 13px", borderRadius: "14px", cursor: "pointer", border: `1px solid ${active ? C.accent : C.line}`, background: active ? C.accent : "transparent", color: active ? "#fff" : C.ink2, fontFamily: UI_FONT, whiteSpace: "nowrap", letterSpacing: "-0.01em" }}>
                  {label}{n ? <span style={{ marginLeft: "5px", fontSize: "10px", opacity: 0.7 }}>{n}</span> : null}
                </button>
              );
            })}
          </div>
        </div>
      </div>

      <input ref={fileInput} type="file" accept="application/json,.json" style={{ display: "none" }}
        onChange={e => { const f = e.target.files?.[0]; if (f) doImport(f); e.target.value = ""; }} />

      <div style={{ maxWidth: "780px", margin: "0 auto", padding: "16px 16px 80px", overflowAnchor: "none" }} onClick={() => menuOpen && setMenuOpen(false)}>
        {loading && <div style={{ color: C.ink3, textAlign: "center", padding: "60px" }}>Loading…</div>}
        {visible.map(entry => (
          <div key={entry.id} ref={el => { rowRefs.current[entry.id] = el; }}
            style={{ marginBottom: "10px", borderRadius: "12px", overflow: "hidden", background: C.surface,
              border: highlightId === entry.id ? "none" : `1px solid ${C.bg}`,
              scrollMarginTop: `${headerH + 8}px`,
              boxShadow: highlightId === entry.id ? `0 0 0 2px ${C.accent}` : "0 1px 3px rgba(28,28,30,0.06)", transition: "box-shadow 0.3s" }}>
            <SwipeRow onDelete={() => deleteEntry(entry.id)} disabled={expandedId === entry.id}>
              {expandedId !== entry.id && exitingId !== entry.id && (
                <EntryRow entry={entry} expanded={false} highlight={highlightId === entry.id}
                  showLang={filterLang === "all"} registers={registers}
                  onSetLang={(id, lang) => setEntries(prev => prev.map(e => e.id === id ? { ...e, lang, langManual: true } : e))}
                  onToggle={() => { setExpandedId(entry.id); scrollEntryToTop(entry.id); }} />
              )}
            </SwipeRow>
            {(expandedId === entry.id || exitingId === entry.id) && (
              <EntryEditor entry={entry} allEntries={entries} onUpdate={updateEntry} onPatch={patchEntry}
                onDelete={() => deleteEntry(entry.id)} onGoto={gotoEntry} onCollapse={() => collapseEntry(entry)}
                exiting={exitingId === entry.id}
                onSetLang={(id, lang) => setEntries(prev => prev.map(e => e.id === id ? { ...e, lang, langManual: true } : e))}
                onWordCommitted={tryJumpToExisting} onWordRenamed={propagateRename}
                registers={registers} onEditRegisters={editRegisters} deepseekKey={deepseekKey} />
            )}
          </div>
        ))}
        {/* Spacer: lets the last entry scroll up so its header reaches the top,
            just like any other entry. Only needed while an entry is expanded. */}
        {expandedId && <div style={{ height: "80vh" }} aria-hidden="true" />}
      </div>
    </div>
  );
}

const menuItem = {
  display: "block", width: "100%", textAlign: "left", background: "transparent",
  border: "none", borderBottom: `1px solid ${C.line}`, padding: "11px 14px",
  fontSize: "14px", color: C.ink, cursor: "pointer", fontFamily: UI_FONT,
};

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
