/* studio-validator.jsx — deterministic decodability validator.
   Pure JS, no React. Same code is mirrored in functions/api/[[path]].js
   (validatePassageServer) and agent-service/validator.js so the server and
   Pro service run the same checks. If you change the rules here, change
   them in BOTH mirrors too.

   Exposed as window.STUDIO_VALIDATOR.

   THIS IS THE TRUTH SOURCE. Rule checks must NEVER use LLMs — LLMs
   rubber-stamp and drift. Code doesn't.
*/

(function () {
  /* Universe of grapheme units (longest first for greedy parse).
     This is *all possible* graphemes — not what's "in scope" for a given
     week. The scope-and-sequence (passed in) narrows the taught set.
     Magic-e split digraphs (a_e etc.) are detected separately by pattern.
     Suffixes (-ed, -ing) are detected at word end. */
  const QUADGRAPHS = ["eigh", "ough", "augh"];
  const TRIGRAPHS = ["igh", "tch", "dge", "ear", "air", "are", "ore", "our"];
  const DIGRAPHS = [
    "ai","ay","au","aw","ea","ee","ei","ey","ie","oa","oe","oi","oo","ou","ow","oy","ue","ui","ew",
    "ar","er","ir","or","ur",
    "ch","ck","gh","kn","ng","ph","qu","sh","th","wh","wr",
    "ff","ll","ss","zz",
    /* Soft-c / soft-g positional digraphs (introduced ~ week 24).
       Greedy parser will match these when they appear; in non-soft contexts
       like "cat" the parser picks single 'c' because no `ca` digraph exists.
       "gy" included so the G1 W23 gy token is matchable (energy, gym). */
    "ce","ci","cy","ge","gi","gy"
  ];
  const SINGLE_LETTERS = "abcdefghijklmnopqrstuvwxyz".split("");

  const VOWELS = new Set(["a","e","i","o","u","y"]);
  const isVowel = (ch) => VOWELS.has(ch);
  const isConsonant = (ch) => /[a-z]/.test(ch) && !VOWELS.has(ch);

  /* Common contractions — accept as sight-word-equivalents if their stem
     is decodable. The apostrophe form is what appears in passages. */
  const COMMON_CONTRACTIONS = new Set([
    "don't","won't","can't","isn't","wasn't","weren't","aren't","didn't",
    "haven't","hasn't","hadn't","wouldn't","couldn't","shouldn't",
    "i'm","i'll","i've","i'd","you're","you'll","you've","you'd",
    "he's","she's","he'll","she'll","it's","we're","we'll","we've","we'd",
    "they're","they'll","they've","they'd","that's","there's","here's",
    "what's","who's","where's","let's"
  ]);

  /* Sights-as-an-array of irregular high-frequency words that aren't
     fully decodable through standard scope. Borrowed from Fry top 100. */
  const HARD_IRREGULAR_SIGHT = new Set([
    "the","a","of","to","you","is","are","was","were","said","have","has",
    "do","does","could","would","should","they","there","their","one","two",
    "from","what","who","why","where","because","been","goes","gone",
    "any","many","every","very","want","into","through","again","other","love",
    "people","water","mother","father","sister","brother","friend","laugh"
  ]);

  /* =========================================================
     Build a "taught" map from the passed scope-and-sequence cutoff
     ========================================================= */

  function buildScope(sequence, throughWeek) {
    const taughtSimple = new Set();   // single letters, digraphs, trigraphs, quadgraphs
    const taughtMagicE = new Set();   // vowels where CVCe is taught
    const taughtSuffix = new Set();   // any "-X" entry: -ed, -ing, -nd, -mp, -tion, -ble, -mb, ...
    const taughtStart = new Set();    // any "X-" entry: bl-, st-, scr-, kn-, un-, tele-, ...
    const taughtYVowel = new Set();   // "long_e" (happy) / "long_i" (my) when y-as-vowel is taught
    // Mixed-grade sequences (from getCumulativeScope) tag each row with .grade.
    // For those, the throughWeek filter has already been applied at construction.
    const isMixedGrade = Array.isArray(sequence) && sequence.length > 0 && sequence[0].grade;

    for (const row of sequence) {
      if (!isMixedGrade && row.week > throughWeek) break;
      for (const u of row.new) {
        const g = String(u.g).toLowerCase();
        // y_long_* must be checked BEFORE the "_e" branch — "y_long_e"
        // contains "_e" and would otherwise be misrouted into taughtMagicE.
        if (g === "y_long_i" || g === "y_long_e") {
          taughtYVowel.add(g.slice(2)); // "long_i" | "long_e"
        } else if (g.includes("_e")) {
          taughtMagicE.add(g.replace("_e", ""));
        } else if (g.startsWith("-")) {
          taughtSuffix.add(g.slice(1));
        } else if (g.endsWith("-")) {
          taughtStart.add(g.slice(0, -1));
        } else {
          taughtSimple.add(g);
        }
      }
    }
    // Keep `taughtBlends` alias for back-compat with UI scope-chip rendering.
    return { taughtSimple, taughtMagicE, taughtSuffix, taughtStart, taughtYVowel, taughtBlends: taughtStart };
  }

  /* Phonics morphemes the parser detects — must match server's lists.
     Sorted longest-first so e.g. "-able"/"-ible" win over "-ble" (the parser
     takes the FIRST match — without the sort, no word ever parses with an
     -able/-ible part and G2 W26 target-coverage is unsatisfiable). */
  const INFLECTIONAL_SUFFIXES = ["ing", "est", "ed", "es", "er", "ly", "s"];
  const PHONICS_FINAL_UNITS = [
    "tion", "sion", "ture", "ance", "ence", "age",
    "ble", "dle", "tle", "kle", "ple", "gle", "fle", "zle",
    "ful", "less", "ness", "ment", "able", "ible", "ish",
    "nd", "nt", "nk", "mp", "st", "ft", "sk", "lt", "lp", "ld", "sp", "ct", "pt",
    "mb", "lk"
  ].sort((a, b) => b.length - a.length);
  const PHONICS_INITIAL_UNITS = [
    "super", "under", "inter", "photo", "graph", "phon", "scrib", "script", "spect",
    "over", "post", "tele", "port", "dict",
    "sub", "pre", "geo", "bio",
    "dis", "mis", "un", "re",
    "scr", "str", "spr", "spl", "thr", "shr",
    "bl", "cl", "fl", "gl", "pl", "sl",
    "br", "cr", "dr", "fr", "gr", "pr", "tr",
    "sc", "sk", "sm", "sn", "sp", "st", "sw", "tw",
    "kn", "wr", "gn"
  ];
  const _2_LETTER_BLENDS = new Set([
    "scr","str","spr","spl","thr","shr","kn","wr","gn",
    "bl","cl","fl","gl","pl","sl",
    "br","cr","dr","fr","gr","pr","tr",
    "sc","sk","sm","sn","sp","st","sw","tw"
  ]);

  /* =========================================================
     Parse a single word into grapheme units against a scope.
     Returns { parts: [{ g, taught, kind }], allTaught, untaught: [g,...] }
     kind: 'grapheme' | 'magic-e' | 'suffix' | 'split-vowel-consonant'
     ========================================================= */

  function parseWord(rawWord, scope) {
    const word = rawWord.toLowerCase().replace(/[^a-z']/g, "");
    if (!word) return { parts: [], allTaught: true, untaught: [] };

    /* Step 1 — strip ONE inflectional suffix. Require stem ≥3 letters AFTER
       stripping (protects short function words like "his", "as", "us"). */
    let suffix = null;
    for (const sx of INFLECTIONAL_SUFFIXES) {
      if (word.length - sx.length >= 3 && word.endsWith(sx)) {
        // "-s" is only a suffix after a non-s letter — "miss"/"mess"/"pass"
        // end in the ss digraph, not a plural ("misses" strips -es fine).
        if (sx === "s" && word.endsWith("ss")) continue;
        suffix = sx; break;
      }
    }
    let stem = suffix ? word.slice(0, word.length - suffix.length) : word;

    /* Step 2 — strip ONE phonics final unit (consonant-le, final blend,
       silent-end, final stable syllable, or derivational suffix). */
    let phonicsFinal = null;
    for (const unit of PHONICS_FINAL_UNITS) {
      if (stem.length > unit.length + 1 && stem.endsWith(unit)) {
        phonicsFinal = unit;
        break;
      }
    }
    if (phonicsFinal) stem = stem.slice(0, -phonicsFinal.length);

    /* Step 3 — strip ONE initial blend, silent-start, prefix, or root.
       Blends: only need a following vowel ("sp"+"in" is valid).
       Prefixes/roots: require ≥3 letters remaining so we don't misparse
       short CVC words ("red" → "re-"+"d", "ran" → "ra-"+"n"). */
    let phonicsStart = null;
    for (const unit of PHONICS_INITIAL_UNITS) {
      if (!stem.startsWith(unit)) continue;
      const remaining = stem.length - unit.length;
      if (remaining < 1) continue;
      const after = stem[unit.length];
      const isBlend = _2_LETTER_BLENDS.has(unit);
      if (isBlend) {
        if (isVowel(after)) { phonicsStart = unit; break; }
      } else {
        if (remaining >= 3) { phonicsStart = unit; break; }
      }
    }
    if (phonicsStart) stem = stem.slice(phonicsStart.length);

    const parts = [];
    if (phonicsStart) {
      parts.push({
        g: phonicsStart + "-",
        taught: !!(scope.taughtStart && scope.taughtStart.has(phonicsStart)),
        kind: "start"
      });
    }

    /* Step 4 — CVCe detection on what remains. ALWAYS detect — when magic-e
       isn't taught for the vowel, the v_e part is marked untaught. */
    let trailingSilentE = false;
    if (
      stem.length >= 3 &&
      stem.endsWith("e") &&
      isConsonant(stem[stem.length - 2]) &&
      isVowel(stem[stem.length - 3])
    ) {
      trailingSilentE = true;
    }

    /* Step 5 — greedy left-to-right parse of stem. */
    const stemHasEarlierVowel = (limit) => {
      for (let k = 0; k < limit; k += 1) if (isVowel(stem[k])) return true;
      return false;
    };
    let i = 0;
    while (i < stem.length) {
      if (trailingSilentE && i === stem.length - 1) {
        parts.push({ g: "e(silent)", taught: true, kind: "silent" });
        i += 1;
        continue;
      }
      if (
        trailingSilentE &&
        i === stem.length - 3 &&
        isVowel(stem[i])
      ) {
        const vowel = stem[i];
        parts.push({ g: vowel + "_e", taught: scope.taughtMagicE.has(vowel), kind: "magic-e" });
        i += 1;
        continue;
      }
      if (i + 4 <= stem.length) {
        const quad = stem.slice(i, i + 4);
        if (QUADGRAPHS.includes(quad)) {
          parts.push({ g: quad, taught: scope.taughtSimple.has(quad), kind: "grapheme" });
          i += 4;
          continue;
        }
      }
      if (i + 3 <= stem.length) {
        const tri = stem.slice(i, i + 3);
        if (TRIGRAPHS.includes(tri)) {
          parts.push({ g: tri, taught: scope.taughtSimple.has(tri), kind: "grapheme" });
          i += 3;
          continue;
        }
      }
      if (i + 2 <= stem.length) {
        const di = stem.slice(i, i + 2);
        if (DIGRAPHS.includes(di)) {
          parts.push({ g: di, taught: scope.taughtSimple.has(di), kind: "grapheme" });
          i += 2;
          continue;
        }
      }
      const single = stem[i];
      if (single === "'") {
        i += 1;
        continue;
      }
      /* Final y after a consonant is a VOWEL y — gate on taughtYVowel, not
         the consonant-y grapheme (taught at KG W19). Two-syllable words
         (happy, baby) use y-as-long-e; one-syllable words (my, fly) use
         y-as-long-i, which the curriculum never formally teaches — those
         words are expected to ride the heart-word / Fry sight lists.
         Medial y (gym) stays on the simple 'y' grapheme. */
      const afterConsonant = i > 0
        ? isConsonant(stem[i - 1])
        // Stem may be just "y" after an onset strip ("fly" → fl- + y):
        // check the stripped start's final letter instead.
        : !!(phonicsStart && isConsonant(phonicsStart[phonicsStart.length - 1]));
      if (
        single === "y" &&
        i === stem.length - 1 &&
        afterConsonant
      ) {
        const yKind = stemHasEarlierVowel(i) ? "long_e" : "long_i";
        parts.push({
          g: "y_" + yKind,
          taught: !!(scope.taughtYVowel && scope.taughtYVowel.has(yKind)),
          kind: "y-vowel"
        });
        i += 1;
        continue;
      }
      parts.push({ g: single, taught: scope.taughtSimple.has(single), kind: "grapheme" });
      i += 1;
    }

    /* Step 6 — phonics-final unit (if any). */
    if (phonicsFinal) {
      parts.push({
        g: "-" + phonicsFinal,
        taught: scope.taughtSuffix.has(phonicsFinal),
        kind: "phonics-end"
      });
    }

    /* Step 7 — inflectional suffix. */
    if (suffix) {
      parts.push({
        g: "-" + suffix,
        taught: scope.taughtSuffix.has(suffix),
        kind: "suffix"
      });
    }

    const untaught = parts
      .filter((p) => !p.taught && p.kind !== "silent")
      .map((p) => p.g);
    const allTaught = untaught.length === 0;

    return { parts, allTaught, untaught };
  }

  function likelyProperNameWords(text, curriculumHearts) {
    const counts = {};
    const capitalized = {};
    const words = String(text || "").match(/[A-Za-z]+(?:'[A-Za-z]+)?/g) || [];
    for (const word of words) {
      const lower = word.toLowerCase();
      counts[lower] = (counts[lower] || 0) + 1;
      if (/^[A-Z]/.test(word)) capitalized[lower] = (capitalized[lower] || 0) + 1;
    }
    const names = new Set();
    for (const [word, count] of Object.entries(counts)) {
      if (count < 2 || capitalized[word] !== count) continue;
      if (HARD_IRREGULAR_SIGHT.has(word) || COMMON_CONTRACTIONS.has(word)) continue;
      if (curriculumHearts && curriculumHearts.has(word)) continue;
      names.add(word);
    }
    return names;
  }

  function kgW14QualitySmells(text) {
    const raw = String(text || "");
    const lower = raw.toLowerCase();
    const smells = [];

    const badMatches = lower.match(/\bbad\b/g) || [];
    if (badMatches.length >= 2 || /\b(can|cab|bib|rib|bat)\s+(is|was)\s+(not\s+)?bad\b/.test(lower) || /\bbad bat\b/.test(lower)) {
      smells.push("vague bad/not-bad labels");
    }
    if (/\brib\b/.test(lower)) smells.push("random rib object");
    if (/\bdid\s+(nod|lob|lap|rip)\b/.test(lower) || /\b(lid|can|pan)\s+did\s+(pop|rip|tap)\b/.test(lower)) {
      smells.push("odd object-action phrasing");
    }
    if (/(?:^|[.!?]\s+)(?:And|So)\s+[A-Z]/.test(raw)) smells.push("auto-fix capitalization artifact");
    if (/\bnot mad,\s*and sad\b/.test(lower) || /\bcan dab the bad bat\b/.test(lower) || /\blap at the bib\b/.test(lower)) {
      smells.push("unclear KG story logic");
    }

    return smells;
  }

  /* =========================================================
     Validate a passage against a spec.
     spec: {
       week, targetWords:[...], wordCount, maxSentenceLen,
       sightWordCap, decodabilityFloor, minTargetHits
     }
     sequence: SCOPE_AND_SEQUENCE array
     Returns: { passed, checks: [...], stats: {...}, annotated: [...] }
     ========================================================= */

  function validatePassage(text, spec, sequence) {
    const scope = buildScope(sequence, spec.week);
    const tokens = (text.toLowerCase().match(/[a-z]+(?:'[a-z]+)?/g) || []);
    const distinctTokens = Array.from(new Set(tokens));
    const targets = (spec.targetWords || []).map((w) => w.toLowerCase().trim()).filter(Boolean);
    // FRY_SET fallback only allowed at G1+ (KG must use the curriculum-defined
    // irregular heart-word list only).
    const grade = (spec.grade || "1");
    const scopeGrade = ({ "K": "KG", "1": "G1", "2": "G2", "3": "G2" })[grade] || "G1";
    const allowFrySetFallback = scopeGrade !== "KG";
    const fry = (allowFrySetFallback && window.STUDIO_DATA) ? window.STUDIO_DATA.FRY_SET : new Set();
    // Curriculum-tracked heart words through (scopeGrade, week) — authoritative
    // sight-word list per the curriculum's §10 master lists.
    const curriculumHearts = (window.STUDIO_DATA && window.STUDIO_DATA.heartWordsThroughWeek)
      ? window.STUDIO_DATA.heartWordsThroughWeek(scopeGrade, spec.week)
      : new Set();
    const likelyNames = likelyProperNameWords(text, curriculumHearts);

    /* Classify each distinct token */
    const tokenInfo = {};
    let decodableCount = 0;
    let sightCount = 0;
    const untaughtMap = {};   // grapheme -> example words
    const untaughtWords = [];
    const sightUsed = new Set();

    for (const tok of distinctTokens) {
      // Contraction handled as a sight word if in COMMON_CONTRACTIONS
      if (COMMON_CONTRACTIONS.has(tok)) {
        tokenInfo[tok] = { kind: "contraction", taught: true };
        sightUsed.add(tok);
        continue;
      }
      if (curriculumHearts.has(tok)) {
        tokenInfo[tok] = { kind: "sight", taught: true };
        sightUsed.add(tok);
        continue;
      }
      if (allowFrySetFallback && HARD_IRREGULAR_SIGHT.has(tok)) {
        tokenInfo[tok] = { kind: "sight", taught: true };
        sightUsed.add(tok);
        continue;
      }
      const parsed = parseWord(tok, scope);
      if (parsed.allTaught) {
        tokenInfo[tok] = { kind: "decodable", taught: true, parts: parsed.parts };
        decodableCount += 1;
        continue;
      }
      if (fry.has(tok)) {
        tokenInfo[tok] = { kind: "sight", taught: true };
        sightUsed.add(tok);
        continue;
      }
      tokenInfo[tok] = { kind: "untaught", taught: false, parts: parsed.parts, untaught: parsed.untaught };
      untaughtWords.push(tok);
      for (const g of parsed.untaught) {
        (untaughtMap[g] = untaughtMap[g] || []).push(tok);
      }
    }

    sightCount = sightUsed.size;
    const totalDistinct = distinctTokens.length;
    const decodabilityPct = totalDistinct === 0 ? 0 :
      Math.round(100 * decodableCount / Math.max(1, totalDistinct - sightUsed.size));

    /* Run rule checks */
    const checks = [];

    // 1. Target words inclusion
    if (targets.length) {
      const missing = targets.filter((w) => !tokens.includes(w));
      const minHits = spec.minTargetHits || targets.length;
      const hits = targets.length - missing.length;
      checks.push({
        id: "target-words",
        label: `Target words present (${hits}/${targets.length})`,
        pass: hits >= minHits,
        detail: missing.length ? `Missing: ${missing.join(", ")}` : ""
      });
    }

    // 2. Word count — curriculum §3 length bracket for this (grade, week).
    const totalWords = tokens.length;
    const lenBand = (window.STUDIO_DATA && window.STUDIO_DATA.passageLengthBand)
      ? window.STUDIO_DATA.passageLengthBand(scopeGrade, spec.week) : null;
    if (lenBand) {
      checks.push({
        id: "word-count",
        label: `Word count in range (${totalWords} words, want ${lenBand.min}–${lenBand.max})`,
        pass: totalWords >= lenBand.min && totalWords <= lenBand.max,
        detail: totalWords < lenBand.min ? `Too short — minimum ${lenBand.min}` :
                totalWords > lenBand.max ? `Too long — maximum ${lenBand.max}` : ""
      });
    }

    // 3. Max sentence length
    const sentenceWords = (window.STUDIO_DATA ? window.STUDIO_DATA.sentenceLengths(text) : []);
    const longSentences = sentenceWords.filter((n) => n > (spec.maxSentenceLen || 999));
    if (spec.maxSentenceLen) {
      checks.push({
        id: "sentence-length",
        label: `All sentences ≤ ${spec.maxSentenceLen} words`,
        pass: longSentences.length === 0,
        detail: longSentences.length ? `${longSentences.length} sentence(s) too long (max found: ${Math.max(...longSentences)})` : ""
      });
    }

    // 4. Decodability floor
    if (spec.decodabilityFloor) {
      checks.push({
        id: "decodability",
        label: `Decodability ≥ ${spec.decodabilityFloor}% (got ${decodabilityPct}%)`,
        pass: decodabilityPct >= spec.decodabilityFloor,
        detail: untaughtWords.length
          ? `${untaughtWords.length} untaught word(s): ${untaughtWords.slice(0, 6).join(", ")}${untaughtWords.length > 6 ? "…" : ""}`
          : ""
      });
    }

    // 5. Sight word cap
    if (spec.sightWordCap) {
      checks.push({
        id: "sight-cap",
        label: `Sight words ≤ ${spec.sightWordCap} distinct (got ${sightCount})`,
        pass: sightCount <= spec.sightWordCap,
        detail: sightCount > spec.sightWordCap
          ? `Trim sight words: ${Array.from(sightUsed).slice(0, 10).join(", ")}…`
          : ""
      });
    }

    // 6. Word repetition cap — no content word more than 5×
    const wordFreq = {};
    for (const tok of tokens) wordFreq[tok] = (wordFreq[tok] || 0) + 1;
    const targetSet = new Set(targets);
    const overused = Object.entries(wordFreq)
      .filter(([w, n]) => n > 5 && !HARD_IRREGULAR_SIGHT.has(w) && !targetSet.has(w) && w.length > 3)
      .sort((a, b) => b[1] - a[1]);
    if (overused.length) {
      checks.push({
        id: "word-repetition",
        label: `No word used more than 5× (found ${overused.length})`,
        pass: false,
        detail: overused.slice(0, 5).map(([w, n]) => `"${w}" ${n}×`).join(", ")
      });
    }

    // 6b. Anti-predictable-text — detect sentence-frame repetition like
    //     "I see a cat. I see a dog." which lets students memorize the frame
    //     and skip decoding. This is the #2 Science of Reading red flag.
    const sentTexts = window.STUDIO_DATA
      ? window.STUDIO_DATA.splitSentences(text)
      : [text];
    const openings2 = {};
    for (const s of sentTexts) {
      const words = (s.toLowerCase().match(/[a-z]+(?:'[a-z]+)?/g) || []).slice(0, 2);
      if (words.length < 2) continue;
      const key = words.join(" ");
      openings2[key] = (openings2[key] || 0) + 1;
    }
    const predictableFrames = Object.entries(openings2)
      .filter(([k, n]) => n >= 3)
      .sort((a, b) => b[1] - a[1]);
    if (predictableFrames.length) {
      checks.push({
        id: "predictable-text",
        label: `No predictable sentence frames (found ${predictableFrames.length})`,
        pass: false,
        detail: predictableFrames.slice(0, 3)
          .map(([k, n]) => `"${k}…" ${n}×`).join(", ")
          + " — students memorize the frame instead of decoding."
      });
    }

    // 6c. Duplicate-sentence check — flag exact repeats (normalized).
    const sigCounts = {};
    for (const s of sentTexts) {
      const sig = s.toLowerCase()
        .replace(/[^a-z' ]/g, " ")
        .replace(/\s+/g, " ")
        .trim();
      if (!sig || sig.split(" ").length < 3) continue;
      sigCounts[sig] = (sigCounts[sig] || 0) + 1;
    }
    const duplicates = Object.entries(sigCounts).filter(([s, n]) => n >= 2);
    if (duplicates.length) {
      checks.push({
        id: "duplicate-sentences",
        label: `No duplicate sentences (found ${duplicates.length})`,
        pass: false,
        detail: duplicates.slice(0, 3)
          .map(([s, n]) => `"${s}" ${n}×`).join(" · ")
      });
    }

    // 6d. Mirror-frame detector — flag pairs of sentences with same length
    //     and ≥ 70% positional word match (e.g. "Ben sat on the mat. / Pam sat on the mat.")
    const sentTokens = sentTexts.map((s) =>
      (s.toLowerCase().match(/[a-z]+(?:'[a-z]+)?/g) || [])
    );
    const mirrorPairs = [];
    for (let i = 0; i < sentTokens.length; i++) {
      for (let j = i + 1; j < sentTokens.length; j++) {
        const a = sentTokens[i];
        const b = sentTokens[j];
        if (a.length !== b.length || a.length < 4) continue;
        let matches = 0;
        for (let k = 0; k < a.length; k++) if (a[k] === b[k]) matches++;
        const sim = matches / a.length;
        if (sim >= 0.7 && sim < 1.0) {
          mirrorPairs.push({
            a: sentTexts[i].trim(),
            b: sentTexts[j].trim()
          });
        }
      }
    }
    if (mirrorPairs.length >= 1) {
      checks.push({
        id: "mirror-frames",
        label: `No mirror-frame sentences (found ${mirrorPairs.length})`,
        pass: false,
        detail: mirrorPairs.slice(0, 3)
          .map((p) => `"${p.a}" ↔ "${p.b}"`).join(" · ")
          + " — same structure with substituted words lets students pattern-match instead of decode."
      });
    }

    // 6e. KG W14 story quality — catches phonics-clean but story-weak drafts.
    if (scopeGrade === "KG" && spec.week === 14 && spec.template === "narrative" && targets.length === 0) {
      const storySmells = kgW14QualitySmells(text);
      if (storySmells.length) {
        checks.push({
          id: "story-quality",
          label: `KG story logic is natural (found ${storySmells.length} issue${storySmells.length === 1 ? "" : "s"})`,
          pass: false,
          detail: storySmells.slice(0, 4).join(", ") + " — decodable text still needs a clear, child-retellable story."
        });
      }
    }

    // 6e. Target-pattern coverage — require ≥4 distinct decodable words
    //     featuring this week's NEW graphemes (character names don't count).
    //     "New this week" comes from the week's DECLARED row (row.new), not a
    //     cumulative diff — the diff went empty at week 1 of each grade (the
    //     prior-week clamp) and at formal teaching weeks whose patterns were
    //     previewed earlier (KG W34 CVCe preview vs G1 W2/W3 formal teaching).
    if (spec.week) {
      const currentRow = (sequence || []).find((row) =>
        row.week === spec.week && (!row.grade || row.grade === scopeGrade));
      const newUnits = (currentRow && Array.isArray(currentRow.new)) ? currentRow.new : [];
      const declared = buildScope([{ week: spec.week, new: newUnits }], spec.week);
      const newSimple = declared.taughtSimple;
      const newMagicE = declared.taughtMagicE;
      const newSuffix = declared.taughtSuffix;
      const newStart = declared.taughtStart;
      const newYVowel = declared.taughtYVowel;
      const newCount = newSimple.size + newMagicE.size + newSuffix.size + newStart.size + newYVowel.size;
      if (newCount > 0) {
        const featuringNew = new Set();
        for (const tok of distinctTokens) {
          if (likelyNames.has(tok)) continue;
          if ((allowFrySetFallback && HARD_IRREGULAR_SIGHT.has(tok)) || COMMON_CONTRACTIONS.has(tok)) continue;
          if (curriculumHearts.has(tok)) continue;
          const parsed = parseWord(tok, scope);
          if (!parsed.allTaught) continue;
          const usesNew = parsed.parts.some((p) => {
            if (p.kind === "grapheme") return newSimple.has(p.g);
            if (p.kind === "magic-e") return newMagicE.has(p.g.replace("_e", ""));
            if (p.kind === "suffix" || p.kind === "phonics-end") return newSuffix.has(p.g.replace(/^-/, ""));
            if (p.kind === "start") return newStart.has(p.g.replace(/-$/, ""));
            if (p.kind === "y-vowel") return newYVowel.has(p.g.slice(2));
            return false;
          });
          if (usesNew) featuringNew.add(tok);
        }
        const requiredCount = newCount >= 2 ? 4 : 2;
        if (featuringNew.size < requiredCount) {
          const newPatterns = [
            ...newSimple, ...[...newMagicE].map((v) => v + "_e"),
            ...[...newSuffix].map((s) => "-" + s), ...[...newStart].map((s) => s + "-"),
            ...[...newYVowel].map((y) => "y_" + y)
          ].join(", ");
          checks.push({
            id: "target-coverage",
            label: `New patterns featured in ≥ ${requiredCount} distinct decodable words (got ${featuringNew.size})`,
            pass: false,
            detail: `New this week: ${newPatterns}. ${featuringNew.size === 0 ? "(none — the new patterns don't appear in any decodable word)" : "Featured in: " + Array.from(featuringNew).slice(0, 6).join(", ")}.`
          });
        }
      }
    }

    // 7. Untaught grapheme summary (informational, not a hard pass/fail beyond decodability)
    const untaughtGraphemeList = Object.entries(untaughtMap).map(([g, words]) => ({
      g,
      examples: words.slice(0, 4)
    }));
    if (untaughtGraphemeList.length) {
      checks.push({
        id: "untaught-graphemes",
        label: `No untaught graphemes (found ${untaughtGraphemeList.length})`,
        pass: false,
        detail: untaughtGraphemeList.slice(0, 5).map((u) => `"${u.g}" in ${u.examples.join("/")}`).join(" · ")
      });
    }

    const passed = checks.every((c) => c.pass);

    return {
      passed,
      checks,
      stats: {
        totalWords,
        distinctTokens: totalDistinct,
        decodabilityPct,
        sightCount,
        sightUsed: Array.from(sightUsed),
        untaughtWords,
        untaughtGraphemes: untaughtGraphemeList
      },
      tokenInfo
    };
  }

  /* =========================================================
     Render-helper: annotate text with span markers (return array
     of {text, kind} chunks suitable for React rendering).
     kinds: 'target', 'sight', 'untaught', 'plain'
     ========================================================= */

  function annotatePassage(text, spec, sequence) {
    const result = validatePassage(text, spec, sequence);
    const targets = new Set((spec.targetWords || []).map((w) => w.toLowerCase()));
    const tokenInfo = result.tokenInfo;
    const chunks = [];
    const re = /([A-Za-z]+(?:'[A-Za-z]+)?|[^A-Za-z]+)/g;
    let m;
    while ((m = re.exec(text)) !== null) {
      const piece = m[0];
      if (/^[A-Za-z]/.test(piece)) {
        const lower = piece.toLowerCase();
        let kind = "plain";
        if (targets.has(lower)) kind = "target";
        else if (tokenInfo[lower]?.kind === "sight" || tokenInfo[lower]?.kind === "contraction") kind = "sight";
        else if (tokenInfo[lower]?.kind === "untaught") kind = "untaught";
        chunks.push({ text: piece, kind });
      } else {
        chunks.push({ text: piece, kind: "plain" });
      }
    }
    return { chunks, result };
  }

  /* =========================================================
     Export
     ========================================================= */

  window.STUDIO_VALIDATOR = {
    buildScope,
    parseWord,
    validatePassage,
    annotatePassage,
    TRIGRAPHS,
    DIGRAPHS,
    COMMON_CONTRACTIONS,
    HARD_IRREGULAR_SIGHT
  };
})();
