// scan-engine.jsx — the Lens pipeline: camera → card → text → English → identity
//
//   video frame
//     → sample      downscale to 320px on an offscreen canvas (~8 fps)
//     → detect      find the card quad, then the text bands inside it   [REAL]
//     → recognize   regions → source strings                          [engine]
//     → translate   source strings → English                        [glossary]
//     → identify    name + collector number → CARD_CATALOG row
//
// THE DEFAULT SOURCE LANGUAGE IS ENGLISH. The pipeline is an importer first and
// a translator second: most cards crossing the counter are English prints being
// identified and priced, where `translate` is a pass-through and the work is all
// in detect → recognize → identify. Japanese and Korean add the glossary step
// and the overlay. SCAN_LANGS in data-translate.jsx is the one list of what is
// supported; nothing here should test for a language by hand.
//
// WHAT IS REAL AND WHAT IS NOT — this matters, so it is stated once, here.
// Sampling and detection are real computer vision running on the live camera
// every frame. That is why the overlays stick to the card when you move it:
// the geometry is measured, never faked. Only `recognize` is pluggable, and its
// default implementation (`frame`) substitutes reference text for OCR. Anything
// that comes out of it is tagged `simulated: true` all the way to the pixel.
//
// Why ship a simulated recognizer at all: Korean traineddata is ~15 MB and
// takes 1–3 s per frame on a phone, so even the production design freezes the
// frame before OCR runs. `frame` mode gives the interaction at full frame rate;
// `ocr` mode proves the swap. See rgs/feature-card-translate.md §4.
//
// No dependencies, no build step, no network — same rules as the rest of this
// directory.

/* ═══════════ 1. sampling ═══════════ */

const SAMPLE_W = 320;   // enough detail for edges, cheap enough for 8 fps
const CARD_ASPECT = 5 / 7;

// Draws the current video frame into a reusable offscreen canvas at low res and
// returns its luminance plane. Luma only: every step downstream is structural,
// and colour would triple the memory traffic for nothing.
// `source` is anything drawImage accepts. It is a <video> with a camera behind
// it in the normal case, and the rendered reference scene when there is no
// camera — which means the detector runs for real either way, on real pixels.
function grabLuma(source, canvas) {
  const vw = source.videoWidth || source.naturalWidth || source.width;
  const vh = source.videoHeight || source.naturalHeight || source.height;
  if (!vw || !vh) return null;
  const w = SAMPLE_W, h = Math.round((vh / vw) * SAMPLE_W);
  if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
  const ctx = canvas.getContext('2d', { willReadFrequently: true });
  ctx.drawImage(source, 0, 0, w, h);
  const px = ctx.getImageData(0, 0, w, h).data;
  const luma = new Float32Array(w * h);
  for (let i = 0, p = 0; i < luma.length; i++, p += 4) {
    luma[i] = 0.299 * px[p] + 0.587 * px[p + 1] + 0.114 * px[p + 2];
  }
  return { luma, w, h };
}

/* ═══════════ 2. detection ═══════════ */

// Gradient magnitude, |dx| + |dy|. A full Sobel buys nothing here — everything
// downstream is a projection, which washes out the kernel difference, and this
// version runs in one pass with no intermediate buffers.
function edgeMap({ luma, w, h }) {
  const e = new Float32Array(w * h);
  for (let y = 1; y < h - 1; y++) {
    for (let x = 1; x < w - 1; x++) {
      const i = y * w + x;
      e[i] = Math.abs(luma[i + 1] - luma[i - 1]) + Math.abs(luma[i + w] - luma[i - w]);
    }
  }
  return e;
}

// A high percentile of the edge values — used as the "this is ink, not
// texture" cutoff. Relative to the frame's own contrast, so it adapts to a dim
// room or a glossy card without a magic constant per lighting condition.
function percentile(values, p) {
  const s = Float32Array.from(values).sort();
  return s[Math.min(s.length - 1, Math.max(0, Math.round(p * (s.length - 1))))];
}

// Extent of the hot indices, trimmed at both ends. Used inside a card, where
// the interior gaps are what separate one line of text from the next.
function hotExtent(counts, floorFrac = 0.10, trim = 0.02) {
  let max = 0;
  for (let i = 0; i < counts.length; i++) if (counts[i] > max) max = counts[i];
  if (max === 0) return null;
  const floor = Math.max(1, max * floorFrac);
  const hot = [];
  for (let i = 0; i < counts.length; i++) if (counts[i] >= floor) hot.push(i);
  if (hot.length < 3) return null;
  const cut = Math.floor(hot.length * trim);
  return { start: hot[cut], end: hot[hot.length - 1 - cut], hits: hot.length };
}

/* ── finding the card ──
   The first version of this took the bounding box of everything with a strong
   edge. That works on a clean desk and fails completely in a real room: point
   it at a person holding a card and the box spans the face, the window and the
   card together, the aspect comes out nothing like a card, and it never locks.
   Real-device testing did exactly that.
   The fix is to stop asking "where is the ink" and start asking "where is there
   a card-shaped rectangle". A card is not just contrast — it is a closed
   rectangular border at a known aspect with print inside it. A face has no such
   border. A window has the border but nothing inside. Scoring explicit
   candidate rectangles tests all three properties at once. */

// Prefix sums over the edge map, so the energy inside any rectangle is four
// lookups instead of a loop. This is what makes an exhaustive search over
// thousands of candidates affordable at frame rate.
function integralImage(e, w, h) {
  const I = new Float64Array((w + 1) * (h + 1));
  for (let y = 0; y < h; y++) {
    let row = 0;
    for (let x = 0; x < w; x++) {
      row += e[y * w + x];
      I[(y + 1) * (w + 1) + (x + 1)] = I[y * (w + 1) + (x + 1)] + row;
    }
  }
  return I;
}

// Sum over [x0,x1) × [y0,y1).
function rectSum(I, w, x0, y0, x1, y1) {
  const W = w + 1;
  return I[y1 * W + x1] - I[y0 * W + x1] - I[y1 * W + x0] + I[y0 * W + x0];
}

// Where we ask the user to put the card. Having a guide is worth it twice over:
// it tells someone what the app wants, and it gives the search a prior, which
// is what breaks ties in a cluttered room. The bias is gentle so a card held
// off to one side is still found.
const GUIDE = { h: 0.74, cy: 0.47 };

function guideRect(w, h) {
  const gh = h * GUIDE.h, gw = gh * CARD_ASPECT;
  return { x0: (w - gw) / 2, y0: h * GUIDE.cy - gh / 2, x1: (w + gw) / 2, y1: h * GUIDE.cy + gh / 2 };
}

function boxOverlap(a, b) {
  const iw = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
  const ih = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0);
  if (iw <= 0 || ih <= 0) return 0;
  const inter = iw * ih;
  return inter / ((a.x1 - a.x0) * (a.y1 - a.y0) + (b.x1 - b.x0) * (b.y1 - b.y0) - inter);
}

// Score one candidate rectangle.
//
// The four sides are measured SEPARATELY and combined with a minimum, which is
// the single most important line in the detector. A mean around the whole
// perimeter is happy with a rectangle that has two strong sides and two weak
// ones — which is exactly what you get by aligning a box to a couple of lines
// of text, or to a face and a window edge. Taking the weakest side asks "is
// this a CLOSED rectangle", and only a real card border answers yes.
function scoreCandidate(I, w, h, x, y, cw, ch, guide, globalMean) {
  const t = Math.max(2, Math.round(ch * 0.03));
  const ox0 = Math.max(0, x - t), oy0 = Math.max(0, y - t);
  const ox1 = Math.min(w, x + cw + t), oy1 = Math.min(h, y + ch + t);
  const ix0 = x + t, iy0 = y + t, ix1 = x + cw - t, iy1 = y + ch - t;
  if (ix1 <= ix0 || iy1 <= iy0) return null;

  const band = (a, b, c, d) => {
    const area = (c - a) * (d - b);
    return area > 0 ? rectSum(I, w, a, b, c, d) / area : 0;
  };
  const sides = Math.min(
    band(ox0, oy0, ox1, iy0),   // top
    band(ox0, iy1, ox1, oy1),   // bottom
    band(ox0, iy0, ix0, iy1),   // left
    band(ix1, iy0, ox1, iy1)    // right
  );
  const interior = rectSum(I, w, ix0, iy0, ix1, iy1) / ((ix1 - ix0) * (iy1 - iy0));

  // Both properties, not either: a lit window has the border and no print; a
  // patch of patterned wall has print and no border.
  //
  // The gate is measured against the FRAME, not against this candidate's own
  // border. Scaling it by `sides` looks reasonable and is backwards: it
  // punishes a candidate for having a strong border, so a real card scores
  // below a weak-bordered rectangle drawn around its own art panel. That is
  // precisely what the cluttered-scene test caught.
  const ink = Math.min(1, interior / (globalMean * 0.6 + 1e-6));
  const near = 0.75 + 0.25 * boxOverlap({ x0: x, y0: y, x1: x + cw, y1: y + ch }, guide);

  // Prefer the outermost card-shaped rectangle. Without this the search is
  // just as happy with a card-shaped box drawn around the art panel and a
  // couple of text lines INSIDE the card — all four of those sides are strong
  // too. We always want the whole card, so bias toward area.
  const size = Math.pow(ch / h, 0.35);
  return { score: sides * ink * near * size, sides, interior };
}

// Finds the card as an axis-aligned box.
//
// Deliberately NOT a perspective quad. A four-corner homography would let the
// overlay skew with a tilted card, but it needs contour tracing and corner
// refinement to be stable, and an unstable quad is worse than an honest
// rectangle — the overlay would swim. Tilt weakens the border score, so it
// shows up as low confidence and "line the card up" rather than a wrong box.
//
// Aspect is fixed by construction rather than measured, so confidence comes
// from how much stronger the border is than the rest of the frame — which is
// the thing that actually distinguishes a card from a face.
//
// Returns normalized coords (0..1 of the frame) or null.
function findCardQuad(frame) {
  const { w, h } = frame;
  const e = edgeMap(frame);
  const I = integralImage(e, w, h);
  const globalMean = rectSum(I, w, 0, 0, w, h) / (w * h);
  if (!(globalMean > 0)) return null;

  const guide = guideRect(w, h);
  const fits = (x, y, cw, ch) => x >= 0 && y >= 0 && x + cw <= w && y + ch <= h && cw >= 12;
  let best = null;
  const consider = (x, y, ch) => {
    const cw = Math.round(ch * CARD_ASPECT);
    if (!fits(x, y, cw, ch)) return;
    const s = scoreCandidate(I, w, h, x, y, cw, ch, guide, globalMean);
    if (s && (!best || s.score > best.score)) best = { x, y, cw, ch, ...s };
  };

  // Coarse sweep: every card-shaped rectangle on a grid, at nine scales.
  const step = Math.max(3, Math.round(w * 0.03));
  const SCALES = 9;
  const minH = Math.round(h * 0.30), maxH = Math.round(h * 0.97);
  const scaleGap = Math.max(2, Math.round((maxH - minH) / (SCALES - 1)));
  for (let ch = minH; ch <= maxH; ch += scaleGap) {
    for (let y = 0; y + ch <= h; y += step) {
      for (let x = 0; x + Math.round(ch * CARD_ASPECT) <= w; x += step) consider(x, y, ch);
    }
  }
  if (!best) return null;

  // Local refinement. The coarse grid gets within about half a step of the
  // card; without this the box is card-shaped but offset, and every overlay
  // inherits the offset.
  let ds = step, dh = scaleGap;
  for (let pass = 0; pass < 3; pass++) {
    ds = Math.max(1, Math.round(ds / 2));
    dh = Math.max(1, Math.round(dh / 2));
    const b = best;
    for (let ch = b.ch - dh; ch <= b.ch + dh; ch += dh) {
      for (let y = b.y - ds; y <= b.y + ds; y += ds) {
        for (let x = b.x - ds; x <= b.x + ds; x += ds) consider(x, y, ch);
      }
    }
  }

  // Confidence is TWO signals multiplied, not one.
  //
  // Border strength alone said 0.61 for a room with no card in it — the window
  // frame is a strong closed rectangle at close to card aspect, so it wins the
  // search and then reports itself as a find. The suite caught that: brackets
  // painted over an empty room.
  //
  // What a window does not have is print inside it. Interior energy relative to
  // the frame separates the two cleanly — 0.96 for the empty room against
  // 2.11–3.82 for every scene that does contain a card — so a rectangle has to
  // satisfy both to be believed.
  const ratio = best.sides / globalMean;
  const interiorRatio = best.interior / globalMean;
  const border = Math.max(0, Math.min(1, (ratio - 1.1) / 1.5));
  const printed = Math.max(0, Math.min(1, interiorRatio - 1));
  return {
    x: best.x / w, y: best.y / h, w: best.cw / w, h: best.ch / h,
    conf: border * printed,
    ratio, interiorRatio, border, printed,
  };
}

// Text bands inside the card. Same ink-count profile, read the other way: here
// the interior gaps are the signal, because they are what separates one line of
// text from the next. Returns boxes in CARD-FRACTIONAL coordinates (0..1 of the
// quad) so they survive the card moving.
function findTextBands(frame, quad) {
  const { luma, w, h } = frame;
  const qx = Math.max(0, Math.round(quad.x * w)), qy = Math.max(0, Math.round(quad.y * h));
  const qw = Math.min(w - qx, Math.round(quad.w * w)), qh = Math.min(h - qy, Math.round(quad.h * h));
  if (qw < 8 || qh < 8) return [];

  // Crop, then re-run the edge map on the crop so thresholds are relative to
  // the card rather than to whatever is behind it.
  const sub = new Float32Array(qw * qh);
  for (let y = 0; y < qh; y++) {
    for (let x = 0; x < qw; x++) sub[y * qw + x] = luma[(qy + y) * w + (qx + x)];
  }
  const e = edgeMap({ luma: sub, w: qw, h: qh });
  const thresh = percentile(e, 0.99) * 0.30;

  // Ink count per row, ignoring the card's own left and right borders — they
  // are hot on every row and would flatten the profile that separates lines.
  const inset = Math.max(1, Math.round(qw * 0.04));
  const rows = new Int32Array(qh);
  for (let y = 0; y < qh; y++) {
    for (let x = inset; x < qw - inset; x++) if (e[y * qw + x] >= thresh) rows[y]++;
  }
  let peak = 0;
  for (let y = 0; y < qh; y++) if (rows[y] > peak) peak = rows[y];
  if (peak < 2) return [];

  const floor = Math.max(1, peak * 0.14);
  const mergeGap = Math.max(1, Math.round(qh * 0.012));
  const bands = [];
  let start = -1, lastHot = -1;
  for (let y = 0; y <= qh; y++) {
    const hot = y < qh && rows[y] >= floor;
    if (hot) { if (start < 0) start = y; lastHot = y; }
    else if (start >= 0 && (y === qh || y - lastHot > mergeGap)) {
      bands.push({ y0: start, y1: lastHot });
      start = -1;
    }
  }

  return bands
    // A band thinner than 1% of the card is noise; thicker than 22% is the art
    // panel, not a line of text.
    .filter(b => (b.y1 - b.y0) / qh >= 0.010 && (b.y1 - b.y0) / qh <= 0.22)
    .map(b => {
      // Horizontal extent of this band only.
      const cols = new Int32Array(qw);
      for (let y = b.y0; y <= b.y1; y++) {
        for (let x = inset; x < qw - inset; x++) if (e[y * qw + x] >= thresh) cols[x]++;
      }
      const span = hotExtent(cols, 0.12, 0);
      const x0 = span ? span.start : inset;
      const x1 = span ? span.end : qw - inset;
      return {
        x: x0 / qw, y: b.y0 / qh,
        w: Math.max(0.04, (x1 - x0 + 1) / qw),
        h: Math.max(0.012, (b.y1 - b.y0 + 1) / qh),
      };
    })
    .slice(0, 14);
}

// Exponential smoothing on the quad. Detection is per-frame and independent, so
// raw boxes jitter by a pixel or two even on a still card; unsmoothed, that
// reads as the overlay vibrating.
function smoothBox(prev, next, alpha = 0.35) {
  if (!prev) return next;
  if (!next) return null;
  const m = (a, b) => a + (b - a) * alpha;
  return {
    ...next,
    x: m(prev.x, next.x), y: m(prev.y, next.y),
    w: m(prev.w, next.w), h: m(prev.h, next.h),
    conf: m(prev.conf, next.conf),
  };
}

/* ═══════════ 3. recognizers ═══════════ */

// The pluggable step. One method:
//   recognize(ctx) -> [{ role, box, text, lang, conf, simulated }]
// where box is in card-fractional coordinates.

// `frame` — maps each detected band to a card-frame role by geometry, then
// reads the reference card's text for that role. Real tracking, substituted
// text. Everything it emits is flagged.
const FrameEngine = {
  id: 'frame',
  label: 'Frame',
  simulated: true,
  note: 'Card tracking is live. Text comes from a reference card, not from OCR.',
  async recognize({ bands, reference, lang }) {
    // 'auto' means "whatever this reference print is". An explicit language
    // wins, including English — which is the default, and which used to fall
    // through to the Korean string because `en` did not exist on the regions.
    const src = !lang || lang === 'auto' ? reference.lang : lang;

    // Snap regions onto measured bands so plates land on real ink — but
    // ONE-TO-ONE, greedily, closest pair first. Letting two regions claim the
    // same band is what makes plates stack on top of each other: a card's
    // Weakness and Retreat share a line, and the band detector correctly sees
    // one band there. A region that loses its band falls back to frame
    // geometry, which never overlaps because the frame template doesn't.
    const TOL = 0.055;   // within 5.5% of card height
    const pairs = [];
    reference.regions.forEach((region, ri) => {
      const ry = region.box[1] + region.box[3] / 2;
      bands.forEach((b, bi) => {
        const err = Math.abs((b.y + b.h / 2) - ry);
        if (err < TOL) pairs.push({ ri, bi, err });
      });
    });
    pairs.sort((a, b) => a.err - b.err);
    const takenRegion = new Set(), takenBand = new Set(), snapOf = {};
    pairs.forEach(({ ri, bi }) => {
      if (takenRegion.has(ri) || takenBand.has(bi)) return;
      takenRegion.add(ri); takenBand.add(bi); snapOf[ri] = bands[bi];
    });

    /* ── reject a snap that crushes the region above it ──
       One-to-one snapping stops two regions sharing a band, but it does not
       stop a snap from moving a region UP into the one above it. When that
       happens the upper region ends up with a few pixels of room before the
       next one starts, and the overlay has no good answer: the plate either
       covers its neighbour or is cut down to a sliver too short for one line.
       That produced a 9px-tall plate showing nothing on the ability text.

       The frame template's own geometry is laid out so its regions never
       collide, so it is the safe answer. A snap that leaves less than one
       line's worth of gap is worse than no snap, and gets dropped. */
    // 0.05 of card height is one line of overlay type at its minimum size, and
    // it is also the tightest gap the frame template itself uses (stage → name
    // is 0.052). So it is not a tuned constant: a snap that packs two regions
    // closer than the card's own layout ever does is the snap that is wrong.
    const MIN_GAP = 0.05;
    const boxOf = (ri) => {
      const s = snapOf[ri];
      const t = reference.regions[ri].box;
      return s ? s : { x: t[0], y: t[1], w: t[2], h: t[3] };
    };
    const spansOverlap = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w;

    // Dropping one snap changes the gaps around it, so re-scan after each fix.
    // Bounded by the region count: every pass deletes a snap, and there are
    // only so many to delete.
    for (let pass = 0; pass < reference.regions.length; pass++) {
      const order = reference.regions.map((_, i) => i).sort((i, j) => boxOf(i).y - boxOf(j).y);
      let fixed = false;
      for (let k = 0; k < order.length - 1 && !fixed; k++) {
        const hi = order[k], lo = order[k + 1];
        const bHi = boxOf(hi), bLo = boxOf(lo);
        if (bLo.y - bHi.y >= MIN_GAP || !spansOverlap(bHi, bLo)) continue;
        // Unsnap whichever one's template position reopens the gap. The lower
        // region first — it is the one that moved up into its neighbour.
        if (snapOf[lo] && reference.regions[lo].box[1] - bHi.y >= MIN_GAP) { delete snapOf[lo]; fixed = true; }
        else if (snapOf[hi] && bLo.y - reference.regions[hi].box[1] >= MIN_GAP) { delete snapOf[hi]; fixed = true; }
      }
      if (!fixed) break;
    }

    return reference.regions.map((region, ri) => {
      const snapped = snapOf[ri];
      const box = snapped
        ? { x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }
        : { x: region.box[0], y: region.box[1], w: region.box[2], h: region.box[3] };
      // Fall back to English, never to Korean. A print with no string for the
      // language asked for should degrade to the language everything else on
      // the screen is in, not to whichever one happened to be written first.
      const text = region[src] || region.en || region.ko;
      return {
        role: region.role,
        box,
        text,
        // Report the language of the string actually returned. Claiming `src`
        // after falling back would send English text into the Korean glossary.
        lang: region[src] ? src : (region.en ? 'en' : 'ko'),
        conf: snapped ? 0.9 : 0.55,
        snapped: !!snapped,
        simulated: true,
      };
    });
  },
};

// `ocr` — the real thing. Lazily pulls Tesseract.js and its traineddata off a
// CDN the first time it is switched on. It is opt-in and not the default for
// two reasons: the Korean model is ~15 MB, and a static site cannot vendor it.
//
// English is the cheap case and the default one: `eng` alone is roughly a
// quarter of the Korean download, so the language most cards are actually in is
// also the fastest to get running.
//
// This is written to fail loudly and fall back, because a scanner that silently
// stops reading is worse than one that says it cannot.
function createOcrEngine() {
  // The worker is cached BY LANGUAGE. Traineddata is baked in at creation, so a
  // worker built for Korean will happily read a Japanese card and return
  // confident nonsense — which is worse than an error, because nothing
  // downstream can tell it apart from a good read.
  let worker = null, workerLangs = null, loading = null, loadingLangs = null, loadErr = null;

  const TESSERACT_CDN = 'https://unpkg.com/tesseract.js@5.1.1/dist/tesseract.min.js';
  // Subresource integrity for the CDN script. Every other unpkg script on this
  // page pins one; this is arbitrary third-party code executing with full page
  // scope, so it should too.
  //
  // ⚠️ UNSET — the build sandbox cannot reach unpkg (egress policy returns 403
  // on CONNECT), so the digest could not be computed here, and a WRONG hash
  // fails closed and breaks OCR entirely. Fill it from any networked machine:
  //
  //   curl -sS https://unpkg.com/tesseract.js@5.1.1/dist/tesseract.min.js \
  //     | openssl dgst -sha384 -binary | openssl base64 -A
  //
  // then paste as 'sha384-<digest>'. Until it is set the script loads without
  // integrity, exactly as before — this is plumbing plus a known gap, not a fix.
  const TESSERACT_SRI = null;

  function loadScript(url) {
    return new Promise((resolve, reject) => {
      const s = document.createElement('script');
      s.src = url; s.crossOrigin = 'anonymous';
      if (TESSERACT_SRI) s.integrity = TESSERACT_SRI;
      s.onload = resolve;
      s.onerror = () => reject(new Error(
        'could not load ' + url + (TESSERACT_SRI ? ' (blocked, offline, or integrity mismatch)' : '')));
      document.head.appendChild(s);
    });
  }

  async function ensure(langs) {
    if (loadErr) throw loadErr;
    if (worker && workerLangs === langs) return worker;
    if (loading && loadingLangs === langs) return loading;

    // Either the first load, or a language switch. Take the old worker out of
    // service before awaiting anything so no caller can pick it up mid-swap.
    const stale = worker;
    worker = null; workerLangs = null;
    loadingLangs = langs;
    loading = (async () => {
      if (stale) { try { await stale.terminate(); } catch (e) { /* already gone */ } }
      if (!window.Tesseract) await loadScript(TESSERACT_CDN);
      // 'eng' / 'jpn+eng' / 'kor+eng' — English is in every combination
      // because the collector number and the 'ex' suffix are ASCII on every
      // print, and alone it is the whole job for an English card.
      const w = await window.Tesseract.createWorker(langs);
      worker = w; workerLangs = langs;
      loading = null; loadingLangs = null;
      return w;
    })().catch(err => { loadErr = err; loading = null; loadingLangs = null; throw err; });
    return loading;
  }

  return {
    id: 'ocr',
    label: 'OCR',
    simulated: false,
    note: 'Tesseract.js, loaded on demand. Freeze the frame — OCR is ~1–3 s.',
    get error() { return loadErr; },
    async recognize({ canvas, quad, bands, lang }) {
      // Read the traineddata off the one language table rather than testing for
      // Japanese and treating everything else as Korean — which is what made
      // an English scan pull the 15 MB Korean model and read it with the wrong
      // one. An unknown or missing language now lands on English, the default.
      const langs = scanLang(lang).ocr;
      const w = await ensure(langs);
      const cw = canvas.width, ch = canvas.height;
      const out = [];
      for (const b of bands) {
        // Band coords are card-fractional; OCR wants frame pixels.
        const rect = {
          left: Math.round((quad.x + b.x * quad.w) * cw),
          top: Math.round((quad.y + b.y * quad.h) * ch),
          width: Math.max(4, Math.round(b.w * quad.w * cw)),
          height: Math.max(4, Math.round(b.h * quad.h * ch)),
        };
        const { data } = await w.recognize(canvas, { rectangle: rect });
        const text = (data.text || '').trim();
        if (!text) continue;
        out.push({
          role: null, box: b, text,
          lang: detectLang(text),
          conf: (data.confidence || 0) / 100,
          snapped: true, simulated: false,
        });
      }
      return out;
    },
    async dispose() {
      const w = worker;
      worker = null; workerLangs = null; loading = null; loadingLangs = null;
      if (w) { try { await w.terminate(); } catch (e) { /* already gone */ } }
    },
  };
}

/* ═══════════ 4. translation ═══════════ */

// Longest-match walk over the lexicon. Works for both languages: Korean has
// spaces but glues particles onto stems, Japanese has no spaces at all, and a
// longest-match walk handles both without a tokenizer.
//
// Returns { en, state, conf, parts } where state is one of:
//   matched     every token resolved     → safe to price against
//   partial     some tokens resolved
//   romanized   nothing resolved; transliterated so a human can still act
//   unknown     nothing to say
function translateText(text, lang) {
  const raw = (text || '').trim();
  if (!raw) return { en: '', state: 'unknown', conf: 0, parts: [] };

  const src = lang && lang !== 'auto' ? lang : detectLang(raw);
  if (src === 'en') return { en: raw, state: 'matched', conf: 1, parts: [{ src: raw, en: raw, hit: true }] };

  // Whole-string first: card rules text is templated, and a sentence match
  // reads like English instead of like a word list.
  const whole = lookup(raw, src);
  if (whole) {
    return {
      // 'card' is confirmed against the publisher's own English print, so it is
      // at least as trustworthy as 'high' — only 'check' is discounted.
      en: whole.en, state: 'matched', conf: whole.conf === 'check' ? 0.75 : 1,
      parts: [{ src: raw, en: whole.en, hit: true, conf: whole.conf, kind: whole.kind }],
      entry: whole,
    };
  }

  const keys = (LEX_INDEX.keys[src] || []);
  const parts = [];
  let i = 0, unmatched = '';
  const flush = () => {
    if (!unmatched) return;
    const t = unmatched.trim();
    if (t) parts.push({ src: t, en: null, hit: false });
    unmatched = '';
  };

  while (i < raw.length) {
    // ASCII runs (collector numbers, damage values, 'ex') pass straight
    // through — they are already language-neutral.
    const ascii = /^[0-9A-Za-z][0-9A-Za-z./-]*/.exec(raw.slice(i));
    if (ascii) {
      flush();
      parts.push({ src: ascii[0], en: ascii[0], hit: true, kind: 'literal' });
      i += ascii[0].length;
      continue;
    }
    let hit = null;
    for (const k of keys) {
      if (k && raw.startsWith(k, i)) { hit = k; break; }
    }
    if (hit) {
      flush();
      const e = LEX_INDEX.map[src].get(hit);
      parts.push({ src: hit, en: e.en, hit: true, conf: e.conf, kind: e.kind });
      i += hit.length;
      // Korean particles glue onto the stem; drop the one that follows a hit.
      if (src === 'ko') {
        const rest = raw.slice(i);
        const p = KO_PARTICLE_RE.exec(rest);
        if (p) i += p[0].length;
      }
      continue;
    }
    unmatched += raw[i];
    i++;
  }
  flush();

  // Literals — collector numbers, damage values, 'ex' — count as resolved.
  // They are already English; a card number is not a failed translation, and
  // flagging '161/131' as an unresolved guess would suppress its own price.
  const resolved = parts.filter(p => p.hit).length;
  const glossary = parts.filter(p => p.hit && p.kind !== 'literal').length;
  const misses = parts.filter(p => !p.hit).length;

  // Romanize what did not resolve, so an unknown name lands as "Rijamong"
  // rather than as a hole. Marked, and never priced against.
  const romanize = src === 'ko' ? romanizeKo : romanizeJa;
  const rendered = parts.map(p => {
    if (p.hit) return p.en;
    const r = romanize(p.src);
    p.roman = r;
    return r;
  });

  const en = rendered.join(' ')
    .replace(/\s+([.,。、!?])/g, '$1')
    .replace(/\s{2,}/g, ' ')
    .replace(/。/g, '.')
    .trim();

  const state = misses === 0 ? 'matched' : resolved === 0 ? 'romanized' : 'partial';
  const softHits = parts.filter(p => p.hit && p.conf === 'check').length;
  const conf = resolved + misses === 0 ? 0
    : (resolved / (resolved + misses)) * (softHits > 0 ? 0.8 : 1) * (glossary === 0 && misses > 0 ? 0.5 : 1);

  return { en, state, conf, parts };
}

// Built from the one particle list in data-translate.jsx, so the two can't
// drift. Longest-first is already guaranteed there and matters here: '에게'
// must win over '에'.
const KO_PARTICLE_RE = new RegExp('^(?:' + KO_PARTICLES.join('|') + ')');

/* ═══════════ 5. identification ═══════════ */

// Normalized Levenshtein. OCR gets a character wrong far more often than it
// gets a word wrong, so exact matching on a scanned name throws away most
// correct reads.
function similarity(a, b) {
  a = (a || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  b = (b || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  if (!a || !b) return 0;
  if (a === b) return 1;
  const m = a.length, n = b.length;
  let prev = Array.from({ length: n + 1 }, (_, j) => j);
  for (let i = 1; i <= m; i++) {
    const cur = [i];
    for (let j = 1; j <= n; j++) {
      cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
    }
    prev = cur;
  }
  return 1 - prev[n] / Math.max(m, n);
}

// Number-first, because the collector number is the one field that does NOT
// change with language: 161/131 is printed in ASCII on the Korean card exactly
// as on the English one. The name is the tie-breaker, not the key.
function identifyCard(fields) {
  const all = Object.values(fields).join(' ');
  const numMatch = /(\d{1,3})\s*\/\s*(\d{2,3})/.exec(all);
  // The suffix stays on. 'Charizard' and 'Charizard ex' are different cards
  // an order of magnitude apart in price, and stripping 'ex' before comparing
  // deletes the only thing that tells them apart.
  const name = (fields.name || '').trim();

  let pool = CARD_CATALOG, narrowed = false;
  if (numMatch) {
    const [, num, denom] = numMatch;
    const sets = CARD_SETS.filter(s => String(s.printed) === denom || String(s.total) === denom);
    const byNumber = CARD_CATALOG.filter(c =>
      c.number === num && (sets.length === 0 || sets.some(s => s.code === c.setCode)));
    if (byNumber.length) { pool = byNumber; narrowed = true; }
  }

  const ranked = pool
    .map(c => ({ card: c, score: name ? similarity(name, c.name) : 0 }))
    .sort((a, b) => b.score - a.score);
  const top = ranked[0];
  if (!top) return null;

  const number = numMatch ? numMatch[0].replace(/\s/g, '') : null;
  // A number that narrowed the pool to one row is evidence on its own.
  if (narrowed && pool.length === 1) {
    return { card: top.card, score: Math.max(top.score, 0.9),
             via: top.score >= 0.6 ? 'number+name' : 'number', number };
  }

  if (top.score < 0.6) return null;

  // Without a collector number, a name alone is often ambiguous — a set's
  // Charizard and its Charizard ex read almost identically to a fuzzy match,
  // and picking the higher-scoring one silently would put the wrong price on
  // the counter. Say it's ambiguous and ask for the number instead.
  const runnerUp = ranked[1];
  if (runnerUp && top.score - runnerUp.score < 0.12) {
    return { card: null, ambiguous: true, number,
             candidates: ranked.filter(r => top.score - r.score < 0.12).slice(0, 4).map(r => r.card) };
  }

  return { card: top.card, score: top.score, via: narrowed ? 'number+name' : 'name', number };
}

// What RGS would ask for a NEAR MINT copy, read from the same CARD_CATALOG row
// the inventory prices against.
//
// Note this is the NM comp, not a shelf price: skuAsk() applies a condition
// multiplier per SKU, and a scan has no condition to apply — nobody has graded
// the card in front of the camera yet. So the same card can legitimately show
// $326 here and $277 on an LP row. The UI labels this "Ask, NM" and lists the
// shelf conditions beside it; keep that labelling if this moves.
function scanValuation(card, lang = DEFAULT_SCAN_LANG) {
  const onShelf = INVENTORY.filter(r => r.cardId === card.id);
  const units = onShelf.reduce((s, r) => s + r.qty, 0);
  const ask = card.market && card.market.rawNM != null ? card.market.rawNM : null;
  // CARD_CATALOG prices the English print. For an English scan that is simply
  // the right number; for a Japanese or Korean one it is the wrong print's
  // comp, and the caveat has to be said. Saying it on every scan — which is
  // what a fixed string did — trains people to ignore it on the scans where it
  // matters, so it is now conditional on what was actually scanned.
  const foreignPrint = langTranslates(lang);
  return {
    ask,
    asOf: card.market ? card.market.asOf : null,
    basis: card.market ? card.market.basis : null,
    units,
    conditions: onShelf.map(r => r.condition),
    sourceLang: lang,
    foreignPrint,
    // Korean and Japanese prints trade below the English print on the US
    // market. We do not carry a language multiplier yet, so this is flagged
    // rather than silently applied — see the spec's follow-ups.
    languageDiscountApplied: false,
  };
}

// Did the reader come back in a different script than the one selected?
//
// This only catches the direction that is actually detectable. A `kor+eng`
// worker pointed at an English card returns Latin text, and that is a reliable
// signal — so selecting Korean for an English card is caught outright. The
// reverse is not: an `eng` worker pointed at a Korean card returns Latin
// gibberish, not Hangul, so nothing in the output says "wrong language". That
// case is handled where it shows up — as a failed match — rather than guessed
// at here. Returning null means "no evidence", never "all good".
function languageHint(regions, lang) {
  if (!langTranslates(lang)) return null;
  const read = (regions || []).filter(r => !r.simulated && (r.text || '').trim());
  if (read.length < 2) return null;
  const scripts = read.map(r => detectLang(r.text));
  const latin = scripts.filter(s => s === 'en').length;
  if (latin === read.length) return { suggest: 'en', reason: 'every band read back as Latin script' };
  return null;
}

/* ═══════════ 6. the session ═══════════ */

// Holds everything that has to persist between frames: the offscreen canvas,
// the smoothed quad, the current engine, and the last good read.
// Below this there is no plausible rectangle at all and the guide box is used.
// It is deliberately a floor for "found nothing", NOT a trust threshold.
// Confidence here measures how strong the winning border is, and a wrong lock
// (a window frame, a phone bezel, a face against a doorway) scores just as high
// as a right one — the cluttered-scene test produced false positives at conf
// 1.0. Nothing local distinguishes them, so the honest design is to let the
// user force the guide box rather than pretend a number can tell.
const AUTO_MIN_CONF = 0.2;

function createScanSession() {
  const canvas = document.createElement('canvas');
  let quad = null, lockFrames = 0, lastFrame = null;

  return {
    canvas,
    get quad() { return quad; },
    reset() { quad = null; lockFrames = 0; },

    // One tick. Returns null when there is nothing to draw yet.
    //   reuse — re-read the last captured frame instead of sampling a new one.
    //           This is what makes a frozen frame work: the camera is paused,
    //           but there is still a frame to run OCR against.
    //   force — skip the consecutive-frames lock requirement. On a frozen frame
    //           there is no motion to guard against, and making the user wait
    //           for three ticks that will never come is how OCR hung.
    async step({ source, engine, reference, lang, reuse = false, force = false, mode = 'auto' }) {
      const frame = reuse ? lastFrame : grabLuma(source, canvas);
      if (!frame) return null;
      lastFrame = frame;

      const auto = findCardQuad(frame);

      // Auto-detection is the good path and it is not always right. A real room
      // has faces, windows and a phone bezel in it, and any of them can
      // out-argue a card. So there is always a second path: the guide box the
      // UI already draws, either because nothing was found or because the user
      // asked for it. The card gets lined up and the rest of the pipeline runs
      // unchanged — which is what a check deposit or a passport scan does, for
      // exactly this reason.
      let next;
      if (mode === 'auto' && auto && auto.conf >= AUTO_MIN_CONF) {
        next = { ...auto, source: 'auto' };
      } else {
        const g = guideRect(frame.w, frame.h);
        next = {
          x: g.x0 / frame.w, y: g.y0 / frame.h,
          w: (g.x1 - g.x0) / frame.w, h: (g.y1 - g.y0) / frame.h,
          conf: 0.5, source: 'guide', autoConf: auto ? auto.conf : 0,
        };
      }
      // Don't glide between two different answers — a jump to the guide box
      // should read as a mode change, not as the card sliding away.
      quad = quad && quad.source === next.source ? smoothBox(quad, next, 0.35) : next;

      // Require a few consecutive confident frames before claiming a lock, so
      // a hand passing through the frame doesn't flash a card identity.
      lockFrames = quad.conf >= 0.45 ? Math.min(lockFrames + 1, 10) : 0;
      const locked = force ? lockFrames > 0 || quad.conf >= 0.45 : lockFrames >= 3;

      const bands = findTextBands(frame, quad);
      if (!locked) return { quad, locked: false, bands, regions: [] };

      const reads = await engine.recognize({ canvas, quad, bands, reference, lang });
      const regions = reads.map(r => {
        const tr = translateText(r.text, r.lang);
        return { ...r, en: tr.en, state: tr.state, transConf: tr.conf, parts: tr.parts };
      });

      const fields = {};
      regions.forEach(r => { if (r.role) fields[r.role] = r.en; });
      if (!fields.name) {
        // OCR mode has no roles — the name is the widest band in the top third.
        const top = regions.filter(r => r.box.y < 0.33).sort((a, b) => b.box.w - a.box.w)[0];
        if (top) fields.name = top.en;
        const num = regions.find(r => /\d+\s*\/\s*\d+/.test(r.text));
        if (num) fields.number = num.text;
      }

      const hit = identifyCard(fields);
      const srcLang = !lang || lang === 'auto' ? (reference && reference.lang) || DEFAULT_SCAN_LANG : lang;
      return {
        quad, locked: true, bands, regions, fields,
        // An ambiguous result passes through with card: null — it is a real
        // answer ("needs the collector number"), not a failure to read.
        match: hit && hit.card ? { ...hit, valuation: scanValuation(hit.card, srcLang) } : hit,
        simulated: regions.some(r => r.simulated),
        lang: srcLang,
        hint: languageHint(regions, srcLang),
      };
    },
  };
}

/* ═══════════ 7. camera ═══════════ */

// Rear camera where there is one. Resolution is requested, not required — an
// `exact` constraint fails outright on hardware that can't hit it, and a
// working 480p scan beats a failed 1080p one.
async function openCamera() {
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    return { stream: null, error: 'no-api' };
  }
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
      audio: false,
    });
    return { stream, error: null };
  } catch (err) {
    const kind = err && (err.name === 'NotAllowedError' ? 'denied'
      : err.name === 'NotFoundError' ? 'no-camera' : 'failed');
    return { stream: null, error: kind };
  }
}

Object.assign(window, {
  SAMPLE_W, CARD_ASPECT,
  grabLuma, edgeMap, percentile, hotExtent, integralImage, rectSum,
  guideRect, GUIDE, boxOverlap, scoreCandidate, findCardQuad, findTextBands, smoothBox,
  FrameEngine, createOcrEngine,
  translateText, similarity, identifyCard, scanValuation, languageHint,
  createScanSession, openCamera, AUTO_MIN_CONF,
});
