// screens-scan.jsx — Lens: point the camera at any card, identify it, and get
// what RGS would pay for it. Foreign prints get translated on the way.
//
// ENGLISH IS THE DEFAULT, AND THAT IS THE POINT OF THE SCREEN. The buy pile off
// a Fort Leavenworth collection is overwhelmingly English print, so the common
// job is import — box, read, match to a CARD_CATALOG row, price — with no
// translation involved at all. Japanese and Korean are the second and third
// options and they add a step rather than defining the feature.
//
// For those two the interaction is Google Translate's camera mode: English is
// painted over the card, in place, and tracks it as it moves. For English there
// is nothing to paint, so the plates are suppressed and the card stays visible —
// covering an English card with the words already printed on it would be worse
// than doing nothing. The sheet underneath is what makes it a business tool
// either way: a scanned card resolves to a catalog row and a price out of the
// same helpers Ops uses, so "card on the counter" becomes "priced line item"
// without anyone typing 리자몽 on a US keyboard.
//
// See rgs/feature-card-translate.md for why the translation is a glossary and
// not an API, and scan-engine.jsx for what is measured vs. substituted.

/* ═══════════ the reference scene ═══════════
   Drawn when there is no camera — denied permission, a desktop with no webcam,
   a locked-down demo laptop. It is NOT a picture of the UI's end state: it is a
   card rendered to a canvas, which the real detector then finds, measures and
   tracks exactly as it would a real card in front of a lens. Same code path,
   synthetic pixels. The demo never dead-ends. */

// Portrait, because that is how a phone is held at a buy counter and how a
// card is shaped — a landscape scene would letterbox the stage and shrink the
// card to the point where the overlay type stops being readable.
const SCENE_W = 480, SCENE_H = 660;
const CJK_STACK = "'Noto Sans KR','Noto Sans JP','Apple SD Gothic Neo','Malgun Gothic','Hiragino Sans',system-ui,sans-serif";

function roundRect(g, x, y, w, h, r) {
  g.beginPath();
  g.moveTo(x + r, y);
  g.arcTo(x + w, y, x + w, y + h, r);
  g.arcTo(x + w, y + h, x, y + h, r);
  g.arcTo(x, y + h, x, y, r);
  g.arcTo(x, y, x + w, y, r);
  g.closePath();
}

function drawReferenceScene(canvas, ref, ms, lang) {
  if (canvas.width !== SCENE_W) { canvas.width = SCENE_W; canvas.height = SCENE_H; }
  const g = canvas.getContext('2d');
  const src = !lang || lang === 'auto' ? ref.lang : lang;

  // A desk, not a void. A flat background would make the edge detector's job
  // trivial and the demo dishonest about how well it tracks.
  g.fillStyle = '#2b2620';
  g.fillRect(0, 0, SCENE_W, SCENE_H);
  g.strokeStyle = 'rgba(255,255,255,0.045)';
  g.lineWidth = 1;
  for (let y = 12; y < SCENE_H; y += 26) {
    g.beginPath(); g.moveTo(0, y + Math.sin(y) * 3); g.lineTo(SCENE_W, y); g.stroke();
  }

  // Slow drift and breathe, so the tracking is visibly doing something.
  const dx = Math.sin(ms / 2600), dy = Math.cos(ms / 3400);
  const ch = SCENE_H * 0.80 + dy * 9;
  const cw = ch * CARD_ASPECT;
  const cx = (SCENE_W - cw) / 2 + dx * 16;
  const cy = (SCENE_H - ch) / 2 + dy * 7;

  g.save();
  g.shadowColor = 'rgba(0,0,0,0.5)'; g.shadowBlur = 22; g.shadowOffsetY = 8;
  roundRect(g, cx, cy, cw, ch, 11);
  g.fillStyle = '#F0EADD'; g.fill();
  g.restore();

  // yellow card border, then the inner frame
  roundRect(g, cx + 5, cy + 5, cw - 10, ch - 10, 7);
  g.strokeStyle = '#d8c98a'; g.lineWidth = 6; g.stroke();

  // art panel
  const ax = cx + cw * 0.07, ay = cy + ch * 0.155, aw = cw * 0.86, ah = ch * 0.40;
  const grad = g.createLinearGradient(ax, ay, ax + aw, ay + ah);
  grad.addColorStop(0, '#3d4f6b'); grad.addColorStop(1, '#8a5f8f');
  roundRect(g, ax, ay, aw, ah, 4);
  g.fillStyle = grad; g.fill();
  g.strokeStyle = 'rgba(0,0,0,0.25)'; g.lineWidth = 2; g.stroke();

  // footer bar
  g.fillStyle = 'rgba(0,0,0,0.06)';
  g.fillRect(cx + cw * 0.07, cy + ch * 0.845, cw * 0.86, ch * 0.07);

  // the text, at the region geometry the engine expects
  g.textBaseline = 'top';
  ref.regions.forEach(r => {
    const [rx, ry, rw, rh] = r.box;
    const x = cx + rx * cw, y = cy + ry * ch, w = rw * cw, h = rh * ch;
    // English first in the fallback chain — the scene must never render Korean
    // because the language asked for happened to be missing.
    const text = r[src] || r.en || r.ko;
    let size = Math.max(6, h * (r.role === 'name' ? 0.86 : 0.72));
    g.font = '600 ' + size + 'px ' + CJK_STACK;
    // shrink to fit rather than overflow — a line that spills past the card
    // would put ink outside the quad and confuse the band detector
    const measured = g.measureText(text).width;
    if (measured > w) {
      size = size * (w / measured);
      g.font = '600 ' + size + 'px ' + CJK_STACK;
    }
    g.fillStyle = r.role === 'name' ? '#14100a' : '#241f17';
    g.save();
    g.beginPath(); g.rect(x, y - 2, w + 2, h + 4); g.clip();
    g.fillText(text, x, y);
    g.restore();
  });
}

/* ═══════════ overlay pieces ═══════════ */

/* ── how much room does each plate actually have? ──
   A plate is not its box. It grows: sideways to fit English that runs longer
   than the Korean it covers, and downward because a translated sentence needs
   more lines than the one band it was read from. Both of those were computed
   against the CARD — grow right until the card's edge, grow down by up to 1.9×
   the band — with no idea another plate was already sitting there. On a card
   whose bands are close together that stacks plates on top of each other:
   Weakness ran over Retreat beside it, and the ability text ran over the attack
   text below it. Two overlapping opaque plates are worse than either one alone,
   because the half that survives is not marked as half.

   So the budget is the distance to the neighbour, not the distance to the card
   edge. Two passes, because how far a plate may grow DOWN depends on how wide
   its neighbours ended up growing: a plate only blocks another if their
   horizontal spans actually overlap.

   Everything here is in card-fractional units, same as region.box. */
const PLATE_GUTTER = 0.010;
// A plate never shrinks below this, so a bad snap can't produce a sliver.
const PLATE_MIN_W = 0.07;
// `padding: 2px 5px` in the stylesheet, top and bottom. fitFontSize measures
// text, not the box around it, so the padding has to come off the budget or
// every plate lands a few pixels taller than the space it was fitted into.
const PLATE_PAD_Y = 4;
const PLATE_PAD_X = 10;

function plateSpace(regions) {
  const box = r => ({ x: r.box.x, y: r.box.y, w: r.box.w, h: r.box.h });
  const b = regions.map(box);

  // Pass 1 — width. A neighbour blocks if it starts to the right and the two
  // share any vertical extent, i.e. they are on the same line.
  const availW = b.map((r, i) => {
    let limit = 1 - r.x;
    b.forEach((o, j) => {
      if (i === j) return;
      const sameLine = o.y < r.y + r.h && o.y + o.h > r.y;
      if (sameLine && o.x > r.x) limit = Math.min(limit, o.x - r.x - PLATE_GUTTER);
    });
    // The neighbour wins over "always cover the source band".
    //
    // These two rules genuinely conflict when a region snaps onto a wide
    // measured band and a sibling is sitting inside that span — which is
    // exactly the Weakness/Retreat case, since they share a printed line and
    // the band detector correctly sees ONE band there for the two of them.
    // Only one can win it; the other falls back to frame geometry inside the
    // winner's span. Preferring coverage there let Weakness grow straight over
    // Retreat. Leaving a sliver of the source line showing next to the English
    // is untidy; hiding another plate behind this one destroys information.
    return Math.max(PLATE_MIN_W, limit);
  });

  // Pass 2 — height, using the widened spans from pass 1. `> r.y` and not
  // `>= r.y` matters: Weakness and Retreat share a y exactly, and neither is
  // below the other — that pair is a width problem, already handled above.
  const availH = b.map((r, i) => {
    let limit = 1 - r.y;
    b.forEach((o, j) => {
      if (i === j) return;
      if (!(o.y > r.y + 0.002)) return;
      const overlaps = o.x < r.x + availW[i] && o.x + availW[j] > r.x;
      if (overlaps) limit = Math.min(limit, o.y - r.y - PLATE_GUTTER);
    });
    // Same precedence as width: the neighbour wins. A plate that cannot fit its
    // text in the room above the next one shrinks its type instead, and only
    // spills if it hits the 7px floor — see fitFontSize.
    return Math.max(0.012, limit);
  });

  return regions.map((r, i) => ({ w: availW[i], h: availH[i] }));
}

// One translated plate, positioned over the source region it replaces.
// The plate is opaque on purpose: a translucent overlay over card art is
// unreadable at exactly the moment you need to read it.
function LensPlate({ region, quad, mediaW, mediaH, hidden, space }) {
  const left = (quad.x + region.box.x * quad.w) * 100;
  const top = (quad.y + region.box.y * quad.h) * 100;
  const w = region.box.w * quad.w * 100;
  const h = region.box.h * quad.h * 100;

  // English is materially longer than the Korean or Japanese it covers — "특성"
  // is two glyphs and "Ability" is seven — so a plate pinned to the source
  // box's width would clip every short label on the card. It sizes to its
  // content instead, bounded by `space`: the distance to the next plate, or the
  // card's edge where there is no neighbour.
  //
  // The bound has to reach minWidth as well as maxWidth. CSS resolves
  // `min-width` AFTER `max-width`, so a plate whose region snapped onto a wide
  // measured band kept that band's width as a FLOOR and grew straight through
  // the cap — which is how Weakness went on covering Retreat even once the
  // neighbour maths was right. Both bounds now come off the same number.
  const cardRight = (quad.x + quad.w) * 100;
  const roomW = space ? space.w * quad.w * 100 : Math.max(w, 97 - left);
  const maxW = Math.max(PLATE_MIN_W * quad.w * 100, Math.min(roomW, cardRight - left));
  const minW = Math.min(w, maxW);

  const boxH = region.box.h * quad.h * mediaH;
  // Two different heights, and they are not the same question:
  //   boxH   — the band this plate covers. Sets the TYPE SCALE, so a two-word
  //            label on a wide band can't balloon to twice the size of the text
  //            beside it on the same line.
  //   spaceH — the room before the next plate. Sets how far the text may WRAP.
  // GROW is 1 because spaceH is already the real budget; the old 1.9 was a
  // guess at how far past its band a plate could spill without hitting anything.
  const spaceH = (space ? space.h : region.box.h) * quad.h * mediaH;
  const size = fitFontSize(region.en || '', (maxW / 100) * mediaW - PLATE_PAD_X, spaceH - PLATE_PAD_Y,
                           { max: Math.max(9, Math.min(19, boxH * 1.35)), GROW: 1 });

  // Shrink first, clip last, and never silently.
  //
  // fitFontSize goes all the way down to 7px trying to fit. If even that does
  // not fit — two long translations snapped onto bands a few pixels apart — the
  // plate is capped at the room it has rather than allowed to cover the plate
  // below it. Losing the tail of THIS plate's text costs less than hiding the
  // whole of the next one, and unlike an overlap it can be marked, so the
  // `clipped` state says the words are cut and press-and-hold shows the source.
  const textW = (maxW / 100) * mediaW - PLATE_PAD_X;
  const needH = textHeight(region.en || '', textW, size) + PLATE_PAD_Y;
  const widest = (region.en || '').split(/\s+/).filter(Boolean)
    .reduce((n, word) => Math.max(n, measureTextWidth(word, size)), 0);
  // Cut either way counts. Height is the common case; width happens when a
  // single unbreakable word is wider than the room left by a neighbour, and
  // going unmarked there is what made "Weakne" look like a rendering glitch
  // rather than a plate that ran out of space.
  const clipped = region.state !== 'unknown' && (needH > spaceH + 0.5 || widest > textW + 0.5);

  const cls = region.state === 'matched' ? 'lens-plate'
    : region.state === 'unknown' ? 'lens-plate ghost'
    : 'lens-plate soft';

  return (
    <div className={cls + (hidden ? ' lens-hidden' : '') + (clipped ? ' clipped' : '')}
         style={{
           left: left + '%', top: top + '%',
           minWidth: minW + '%', maxWidth: maxW + '%',
           minHeight: Math.min(h, (space ? space.h : region.box.h) * quad.h * 100) + '%',
           maxHeight: spaceH, fontSize: size,
         }}
         title={clipped ? region.en + '\n\n' + region.text : region.text}>
      {region.state !== 'unknown' && <span>{region.en}</span>}
    </div>
  );
}

// How tall the text renders at a given size. fitFontSize searches with this and
// the clipped check tests against it, so the two cannot disagree about whether
// a plate fits. Line count comes from measured total width over the available
// width — still an approximation of where the browser breaks lines, but built
// on a real measurement rather than an assumed character width.
function textHeight(text, availW, size) {
  if (!text || availW <= 0 || size <= 0) return 0;
  const lines = Math.max(1, Math.ceil(measureTextWidth(text, size) / availW));
  return lines * size * 1.2;
}

/* ── measuring text, for real ──
   This used to estimate a character as 0.52 × the font size. That is roughly
   right for a lowercase sentence and badly wrong for a short bold label:
   "Weakness" at 13px measures 78px, where the estimate said 57px, so the plate
   was built 20px too narrow and cut the word to "Weakne". Any single constant
   is wrong for one of the two cases, because the plates carry both.

   A canvas measures it exactly for the price of one reused 2D context, so the
   estimate is gone. Cached by string+font because this runs for every plate on
   every tick. */
const PLATE_FONT = '600 %spx var(--ff-ui), system-ui, sans-serif';
let measureCtx = null;
const measureCache = new Map();

function measureTextWidth(text, size) {
  if (!text) return 0;
  const key = size + '|' + text;
  const hit = measureCache.get(key);
  if (hit !== undefined) return hit;
  if (!measureCtx) measureCtx = document.createElement('canvas').getContext('2d');
  // The stylesheet's --ff-ui is a custom property a canvas cannot resolve, so
  // the stack is spelled out. It only has to be metrically close.
  measureCtx.font = PLATE_FONT.replace('%s', size).replace('var(--ff-ui), ', "'Hanken Grotesk',");
  const w = measureCtx.measureText(text).width;
  if (measureCache.size > 600) measureCache.clear();
  measureCache.set(key, w);
  return w;
}

// Largest size at which the text wraps into the space available. It has to
// shrink rather than clip — a half-read attack is worse than a small one.
// GROW is how far past the source band's height a plate may spill before the
// type gives instead.
//
// The height test alone is not enough. CSS will not break inside a word, so a
// single long label — "Weakness", one unbreakable token — cannot wrap to
// escape a narrow plate; it overflows and gets cut, no matter how much vertical
// room it has. That is what "Weakne" on the footer bar was. So the longest word
// is a hard width constraint on the size, tested before the wrap fits.
function fitFontSize(text, availW, boxH, { max = 19, min = 7, GROW = 1.9 } = {}) {
  if (max <= min) return min;
  if (!text || availW <= 0 || boxH <= 0) return min;
  const words = text.split(/\s+/).filter(Boolean);
  for (let size = max; size > min; size -= 0.5) {
    // The widest single word has to fit on a line. CSS will not break inside a
    // word, so a label that is one long token cannot wrap its way out of a
    // narrow plate — it overflows and gets cut regardless of vertical room.
    const widest = words.reduce((n, w) => Math.max(n, measureTextWidth(w, size)), 0);
    if (widest > availW) continue;
    if (textHeight(text, availW, size) <= boxH * GROW) return size;
  }
  return min;
}

// The card box. Corner brackets rather than a full outline — a solid rectangle
// over a card reads as a crop tool, brackets read as a viewfinder.
// `guide` styling distinguishes "we found this" from "we are assuming this".
function LensQuad({ quad, locked }) {
  if (!quad) return null;
  const guide = quad.source === 'guide';
  return (
    <div className={'lens-quad' + (locked && !guide ? ' on' : '') + (guide ? ' guide' : '')}
         style={{ left: quad.x * 100 + '%', top: quad.y * 100 + '%', width: quad.w * 100 + '%', height: quad.h * 100 + '%' }}>
      <i /><i /><i /><i />
    </div>
  );
}

/* ═══════════ identity sheet ═══════════ */

function LensIdentity({ result, engine, refCard, liveCamera, lang, onImport, imported }) {
  // Deliberately NOT defaulted to NM. A scan has graded nothing, and NM is the
  // flattering guess — it is the highest multiplier in CONDITIONS, so a wrong
  // default silently overstates the shelf. Picking one is the whole ask.
  const [cond, setCond] = React.useState(null);
  const matchedId = result && result.match && result.match.card ? result.match.card.id : null;
  // A new card in frame is a new decision; the last card's condition must not
  // carry over onto it.
  React.useEffect(() => { setCond(null); }, [matchedId]);

  if (!result || !result.locked) {
    const l = scanLang(lang);
    return (
      <div className="lens-sheet lens-sheet-hint">
        <Icon name="scan" size={19} color="var(--ink-3)" />
        <div>
          <div style={{ fontWeight: 700, fontSize: 14 }}>
            {result && result.quad ? 'Hold the card flat and fill the frame' : 'Point the camera at a card'}
          </div>
          <div className="muted" style={{ fontSize: 11.5, fontFamily: 'var(--ff-mono)', marginTop: 2 }}>
            {l.translates
              ? `${l.label} print · English appears on the card`
              : 'English print · read, matched and priced'}
          </div>
        </div>
      </div>
    );
  }

  // FRAME mode pointed at a real card is the one combination that can lie
  // outright: the plates say "Umbreon ex" and the sheet says $1,050 while the
  // camera is looking at something else entirely. Real-device testing produced
  // exactly that. The engine badge and the notes below are not enough — a price
  // next to a card is a claim about THAT card, so it does not get made.
  if (liveCamera && engine.simulated) {
    return (
      <div className="lens-sheet">
        <div className="lens-sheet-hd">
          <div style={{ minWidth: 0 }}>
            <div className="kicker" style={{ marginBottom: 5 }}>Reference card · not the one in frame</div>
            <div style={{ fontFamily: 'var(--ff-display)', fontWeight: 800, fontSize: 21, letterSpacing: '-0.02em' }}>
              No price for this card
            </div>
          </div>
          <span className="badge badge-low">Frame mode</span>
        </div>
        <p className="muted lens-note">
          The boxes are measured from the frame and are real. The words are not, so in{' '}
          <span className="mono">FRAME</span> mode over a live camera they are withheld
          entirely rather than shown over something nothing has read — the reference card
          this mode would otherwise draw is {refCard.label}. Switch to <b>OCR engine</b> and
          freeze to actually read what you are pointing at, and price it.
        </p>
      </div>
    );
  }

  const m = result.match;
  if (!m) {
    const read = (result.fields && result.fields.name) || '—';
    return (
      <div className="lens-sheet">
        <div className="lens-sheet-hd">
          <div>
            <div className="kicker" style={{ marginBottom: 5 }}>Read, not matched</div>
            <div style={{ fontFamily: 'var(--ff-display)', fontWeight: 800, fontSize: 21, letterSpacing: '-0.02em' }}>{read}</div>
          </div>
          <span className="badge badge-low">No index match</span>
        </div>
        <p className="muted" style={{ fontSize: 12, lineHeight: 1.5, margin: '10px 0 0' }}>
          The text came through but nothing in the {CARD_CATALOG.length}-row index matches it.
          That is expected — the index covers what we have bought, not every card printed.
          Needs a manual look before it gets a price.
        </p>
        {/* The failure mode the English default introduces. An `eng` reader
            pointed at a Japanese card returns Latin gibberish, not kana, so
            nothing in the output can prove the language is wrong — it surfaces
            here, as a card that read but matched nothing. Say so rather than
            let someone conclude the card is not in the index. */}
        {!langTranslates(lang) && (
          <p className="lens-warn">
            If this is a Japanese or Korean print, switch the language above and read it again —
            English is selected, and an English reader returns nonsense on a foreign print rather
            than reporting that it cannot read it.
          </p>
        )}
      </div>
    );
  }

  // Read fine, but the name alone fits more than one row in the index. At a buy
  // counter that is the answer, not a failure: it says exactly what is missing.
  if (m.ambiguous) {
    return (
      <div className="lens-sheet">
        <div className="lens-sheet-hd">
          <div>
            <div className="kicker" style={{ marginBottom: 5 }}>Matched more than one card</div>
            <div style={{ fontFamily: 'var(--ff-display)', fontWeight: 800, fontSize: 21, letterSpacing: '-0.02em' }}>
              Need the collector number
            </div>
          </div>
          <span className="badge badge-low">Ambiguous</span>
        </div>
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 7, marginTop: 12 }}>
          {m.candidates.map(cd => (
            <span key={cd.id} className="badge" style={{ background: 'rgba(255,255,255,0.09)', color: 'var(--ink)', textTransform: 'none', fontSize: 11.5 }}>
              {cd.name} · {cardSet(cd.setCode).name} {cd.number}
            </span>
          ))}
        </div>
        <p className="muted lens-note">
          These trade at very different prices, so no price is shown. Get the bottom-left corner of
          the card in frame — the collector number is printed in ASCII on every language's print and
          settles it on its own.
        </p>
      </div>
    );
  }

  const c = m.card;
  const set = cardSet(c.setCode);
  const v = m.valuation;
  const r = CARD_RARITY[c.rarity] || { short: '—', tier: 0 };

  return (
    <div className="lens-sheet">
      <div className="lens-sheet-hd">
        <div style={{ minWidth: 0 }}>
          <div className="kicker" style={{ marginBottom: 5 }}>
            Matched by {m.via} · {Math.round(m.score * 100)}%
          </div>
          <div style={{ fontFamily: 'var(--ff-display)', fontWeight: 800, fontSize: 23, letterSpacing: '-0.02em', lineHeight: 1 }}>
            {c.name}
          </div>
          <div className="muted" style={{ fontSize: 12.5, marginTop: 5 }}>
            {set.name} · <span className="mono">{c.number}/{set.printed}</span>
            {refCard && engine.simulated ? ' · ' + refCard.label.split('·')[1].trim() : ''}
          </div>
        </div>
        <span className={'badge ' + (r.tier >= 5 ? 'badge-gold' : r.tier === 4 ? 'badge-accent' : 'badge-good')} title={c.rarity}>
          {r.short}
        </span>
      </div>

      <div className="lens-sheet-nums">
        <div>
          <div className="kicker">Ask, NM</div>
          <div className="ops-num" style={{ fontSize: 17, color: v.ask == null ? 'var(--ink-3)' : 'var(--good)' }}>
            {v.ask == null ? 'unpriced' : money0(v.ask)}
          </div>
        </div>
        <div>
          <div className="kicker">On the shelf</div>
          <div className="ops-num" style={{ fontSize: 17 }}>
            {v.units > 0 ? v.units + ' × ' + v.conditions.join('/') : 'none'}
          </div>
        </div>
        <div>
          <div className="kicker">Priced</div>
          <div className="ops-num" style={{ fontSize: 17 }}>{v.asOf || '—'}</div>
        </div>
      </div>

      {/* Two separate claims — where the number came from, and why it is not the
          offer — so they get two lines rather than running together. The second
          line only applies to a foreign print: on an English scan the catalog
          price IS this card's price, and printing the caveat anyway teaches
          people to skip it on the scans where it matters. */}
      <p className="muted lens-note">
        {v.ask == null
          ? 'No market price captured for this row, so no ask is shown rather than a guessed one.'
          : v.basis}
        <br />
        {v.foreignPrint
          ? `${scanLang(v.sourceLang).label} prints trade below the English print on the US market and we
             carry no language multiplier yet — treat this as the English comp, not the offer.`
          : `This is the English print's own comp, at NM. Condition is not graded yet, so it is the
             ceiling for this card rather than the offer.`}
      </p>

      {/* ── import ──
          The other half of "scan slash import": the scan already resolved a
          catalog row, so the only thing standing between it and an inventory
          SKU is the one fact the camera cannot supply. Condition is not
          optional and is not defaulted — see addScannedSku. */}
      <div className="lens-import">
        {imported && imported.cardId === c.id ? (
          <div className="lens-import-done">
            <Icon name="check" size={15} color="var(--good)" sw={2.6} />
            <span>
              {imported.created ? 'Added ' : 'Now '}<b className="mono">{imported.sku}</b>
              {imported.created ? ' to inventory' : ` × ${imported.qty} on the shelf`} ·{' '}
              {condition(imported.condition).label} · cost not set
            </span>
          </div>
        ) : (
          <>
            <div className="kicker" style={{ marginBottom: 8 }}>Import — pick the condition</div>
            <div className="rail" style={{ padding: 0, marginBottom: 10 }}>
              {CONDITIONS.map(cd => (
                <button key={cd.code} className={'chip' + (cond === cd.code ? ' on' : '')}
                        onClick={() => setCond(cd.code)} title={cd.label}>
                  {cd.code} · {money0(v.ask == null ? 0 : v.ask * cd.mult)}
                </button>
              ))}
            </div>
            <button className="btn btn-accent btn-block" disabled={!cond}
                    style={{ fontSize: 14, padding: '12px 18px', opacity: cond ? 1 : 0.45 }}
                    onClick={() => cond && onImport(c.id, cond)}>
              <Icon name="plus" size={16} sw={2.6} />
              {cond ? `Add ${c.name} · ${cond} to inventory` : 'Choose a condition first'}
            </button>
            <p className="muted lens-note" style={{ marginTop: 8 }}>
              A scan is not a purchase, so the row lands with <b>no cost basis</b> and is left out of
              margin until someone records what was paid. Condition has to be yours — nothing has
              graded this card.
            </p>
          </>
        )}
      </div>
    </div>
  );
}

/* ═══════════ the screen ═══════════ */

function ScanScreen({ onBack }) {
  const videoRef = React.useRef(null);
  const sceneRef = React.useRef(null);
  const sessionRef = React.useRef(null);
  const ocrRef = React.useRef(null);
  const mediaRef = React.useRef(null);

  const [cam, setCam] = React.useState('opening');     // opening | live | none
  const [camErr, setCamErr] = React.useState(null);
  const [engineId, setEngineId] = React.useState('frame');
  const [engineErr, setEngineErr] = React.useState(null);
  // English, not 'auto'. Auto was only ever "whatever the reference print is",
  // which is meaningless against a live camera and impossible for OCR — the
  // traineddata is baked into the worker before a single pixel is read, so the
  // language has to be known up front. Defaulting to the language most cards
  // are in beats guessing.
  const [lang, setLang] = React.useState(DEFAULT_SCAN_LANG);
  const [refId, setRefId] = React.useState(REFERENCE_CARDS[0].id);
  const [frozen, setFrozen] = React.useState(false);
  const [boxMode, setBoxMode] = React.useState('auto');   // auto | guide
  const [showSource, setShowSource] = React.useState(false);
  const [working, setWorking] = React.useState(false);
  const [result, setResult] = React.useState(null);
  const [mediaBox, setMediaBox] = React.useState({ w: 0, h: 0 });
  const [imported, setImported] = React.useState(null);
  const [importErr, setImportErr] = React.useState(null);

  const refCard = referenceCard(refId);
  const aspect = cam === 'live' ? null : SCENE_W / SCENE_H;

  if (!sessionRef.current) sessionRef.current = createScanSession();

  // ── camera ──
  React.useEffect(() => {
    let stream = null, dead = false;
    openCamera().then(res => {
      if (dead) { if (res.stream) res.stream.getTracks().forEach(t => t.stop()); return; }
      stream = res.stream;
      if (stream && videoRef.current) {
        videoRef.current.srcObject = stream;
        videoRef.current.play().catch(() => {});
        setCam('live');
      } else {
        setCam('none');
        setCamErr(res.error);
      }
    });
    return () => { dead = true; if (stream) stream.getTracks().forEach(t => t.stop()); };
  }, []);

  // ── the rendered media rect, so plate type can be sized in real pixels ──
  React.useEffect(() => {
    const el = mediaRef.current;
    if (!el || typeof ResizeObserver !== 'function') return;
    const ro = new ResizeObserver(() => setMediaBox({ w: el.clientWidth, h: el.clientHeight }));
    ro.observe(el);
    setMediaBox({ w: el.clientWidth, h: el.clientHeight });
    return () => ro.disconnect();
  }, [cam]);

  // ── the loop ──
  React.useEffect(() => {
    if (cam === 'opening' || frozen) return;
    const engine = engineId === 'ocr' ? (ocrRef.current || (ocrRef.current = createOcrEngine())) : FrameEngine;
    let raf = 0, dead = false, busy = false, last = 0;
    const start = performance.now();

    const tick = async (ts) => {
      raf = requestAnimationFrame(tick);
      // ~8 fps. Faster buys nothing: the card is not moving at 60 Hz and the
      // detector is the expensive part of the frame.
      if (busy || ts - last < 120) return;
      last = ts; busy = true;
      try {
        let source;
        if (cam === 'live') {
          source = videoRef.current;
        } else {
          drawReferenceScene(sceneRef.current, refCard, ts - start, lang);
          source = sceneRef.current;
        }
        if (source) {
          const r = await sessionRef.current.step({ source, engine, reference: refCard, lang, mode: boxMode });
          if (!dead && r) setResult(r);
        }
      } catch (err) {
        if (!dead) {
          // OCR is the only step that can fail this way, and it fails by not
          // being reachable. Say so and fall back rather than going quiet.
          setEngineErr(String((err && err.message) || err));
          setEngineId('frame');
        }
      } finally {
        busy = false;
        if (!dead) setWorking(false);
      }
    };
    raf = requestAnimationFrame(tick);
    return () => { dead = true; cancelAnimationFrame(raf); };
  }, [cam, frozen, engineId, lang, refId, boxMode]);

  // ── the frozen pass ──
  // Freezing stops the live loop, which is the point — but the loop was also
  // the only thing that ever called the recognizer, so freezing made OCR
  // unreachable and left "Reading…" on screen forever. OCR is the mode that
  // NEEDS a frozen frame, so a frozen frame has to run its own single pass.
  React.useEffect(() => {
    if (!frozen) return;
    const engine = engineId === 'ocr' ? (ocrRef.current || (ocrRef.current = createOcrEngine())) : FrameEngine;
    let dead = false;
    setWorking(true);
    (async () => {
      try {
        const r = await sessionRef.current.step({
          engine, reference: refCard, lang, reuse: true, force: true, mode: boxMode,
        });
        if (!dead && r) setResult(r);
      } catch (err) {
        if (!dead) {
          setEngineErr(String((err && err.message) || err));
          setEngineId('frame');
        }
      } finally {
        if (!dead) setWorking(false);
      }
    })();
    return () => { dead = true; };
  }, [frozen, engineId, lang, refId, boxMode]);

  React.useEffect(() => () => { if (ocrRef.current) ocrRef.current.dispose(); }, []);

  React.useEffect(() => {
    const v = videoRef.current;
    if (cam !== 'live' || !v) return;
    if (frozen) v.pause(); else v.play().catch(() => {});
  }, [frozen, cam]);

  // Writing a scan into inventory. Only reachable from a confirmed match — the
  // ambiguous and no-match branches of the sheet never render the control, and
  // FRAME-over-live-camera returns before it, which is the case that must never
  // reach a write: it has read nothing.
  function onImport(cardId, cond) {
    try {
      const res = addScannedSku({ cardId, condition: cond });
      setImportErr(null);
      setImported({ cardId, condition: cond, sku: res.sku, created: res.created, qty: res.qty });
    } catch (err) {
      setImportErr(String((err && err.message) || err));
    }
  }

  const engine = engineId === 'ocr' ? (ocrRef.current || FrameEngine) : FrameEngine;
  const quad = result && result.quad;

  // A simulated recognizer must never put WORDS over a live camera.
  //
  // The sheet already refused to price this case, but the plates are the
  // loudest thing on screen and they were still painting "Umbreon ex" and
  // "161/131" — over a wall, a window blind, whatever the detector happened to
  // lock. A caption sitting on an object is a claim about that object, and
  // FRAME mode has not read the object. Nothing downstream can walk that back.
  //
  // The boxes stay, because the boxes are real: they are measured from the
  // frame every tick and they demonstrate the tracking honestly. Only the text
  // is withheld. Against the no-camera reference scene the words are fine,
  // because there the reference card IS what is on screen.
  const mute = cam === 'live' && engine.simulated;

  // Three overlay states, and the order they resolve in matters:
  //
  //   1. `mute` wins over everything. Live camera + a simulated recognizer means
  //      the bands were measured and nothing read them, and the dashed ghost
  //      outlines are how that is shown honestly. That claim is about the
  //      recognizer, not the language, so it holds for English too.
  //   2. Otherwise, an English card gets NO plates. A plate is opaque by
  //      design, and pasting "Pikachu ex" over a card that already says Pikachu
  //      ex hides the card to tell you what you can already read. The band was
  //      read; there is simply nothing to put over it — and reusing the
  //      could-not-read outline from (1) would say the opposite of what
  //      happened. The measured quad still shows the tracking works, and the
  //      sheet carries the result.
  //   3. Otherwise, translated plates — the Japanese and Korean path.
  const translating = langTranslates(lang);
  const regions = mute
    ? ((result && result.regions) || []).map(r => ({ ...r, en: '', state: 'unknown' }))
    : translating ? ((result && result.regions) || []) : [];
  // Recomputed per render because the regions move: they snap onto measured
  // bands, so the gap between two plates changes as the card does.
  const plateRoom = React.useMemo(() => plateSpace(regions), [regions]);
  const stats = lexiconStats();

  return (
    <div className="screen-push anim-in lens">
      <PushHeader title="Lens" onBack={onBack} trailing={
        <span className={'lens-badge' + (engineId === 'ocr' ? ' live' : '')}>
          {engineId === 'ocr' ? 'OCR' : 'FRAME'}
        </span>
      } />

      <div className="lens-stage">
        <div className="lens-media" ref={mediaRef} style={aspect ? { aspectRatio: aspect } : undefined}>
          <video ref={videoRef} className="lens-video" playsInline muted
                 style={{ display: cam === 'live' ? 'block' : 'none' }} />
          <canvas ref={sceneRef} className="lens-video"
                  style={{ display: cam === 'live' ? 'none' : 'block' }} />

          <div className="lens-layer">
            <LensQuad quad={quad} locked={result && result.locked} />
            {quad && regions.map((r, i) => (
              <LensPlate key={r.role || i} region={r} quad={quad} mediaW={mediaBox.w} mediaH={mediaBox.h}
                         hidden={showSource} space={plateRoom[i]} />
            ))}
          </div>

          {cam === 'opening' && <div className="lens-status">Opening the camera…</div>}
          {frozen && <div className="lens-frozen">Frozen</div>}
          {working && <div className="lens-status">Reading…</div>}
        </div>
      </div>

      <div className="lens-controls">
        <button className={'chip' + (frozen ? ' on' : '')} onClick={() => setFrozen(f => !f)}>
          {frozen ? 'Live' : 'Freeze'}
        </button>
        <button className={'chip' + (boxMode === 'guide' ? ' on' : '')}
                onClick={() => { setBoxMode(m => m === 'guide' ? 'auto' : 'guide'); sessionRef.current.reset(); }}
                title="Ignore auto-detect and read whatever is inside the guide box">
          {boxMode === 'guide' ? 'Guide box' : 'Auto box'}
        </button>
        <button className="chip"
                onPointerDown={() => setShowSource(true)}
                onPointerUp={() => setShowSource(false)}
                onPointerLeave={() => setShowSource(false)}>
          Hold for original
        </button>
        <div className="lens-langs">
          <Icon name="globe" size={15} color="var(--ink-3)" />
          {SCAN_LANGS.map(l => (
            <button key={l.id} className={'chip' + (lang === l.id ? ' on' : '')}
                    onClick={() => { setLang(l.id); sessionRef.current.reset(); }}
                    title={l.translates
                      ? l.label + ' print — translated to English on the card'
                      : l.label + ' print — read and imported, nothing to translate'}>
              {l.native}
            </button>
          ))}
        </div>
      </div>

      <div className="lens-scroll">
        <LensIdentity result={result} engine={engine} refCard={refCard} liveCamera={cam === 'live'} lang={lang}
                      onImport={onImport} imported={imported} />
        {importErr && <p className="lens-warn" style={{ margin: '0 0 12px' }}>Could not import: {importErr}</p>}

        {/* What is measured and what is not. This is the whole reason the
            feature is trustworthy, so it is on screen, not in a tooltip. */}
        <div className="lens-truth">
          <div className="kicker" style={{ marginBottom: 7 }}>What you're looking at</div>
          {quad && quad.source === 'guide' ? (
            <p className="lens-warn">
              <b>Not tracking:</b> the dashed guide box is being used instead of a measured one
              {boxMode === 'guide' ? '' : ' — nothing card-shaped stood out from the background'}.
              Line the card up inside it and everything downstream runs the same.
            </p>
          ) : (
            <p>
              <b>Live:</b> the camera, the card box, and every text band inside it — all measured
              from the frame, which is why the overlays track the card when you move it. In a busy
              scene it can lock onto the wrong rectangle — a window, a doorway, a phone bezel — and
              it cannot tell that it has. If the brackets are not on the card, switch to{' '}
              <b>Guide box</b> and line the card up.
            </p>
          )}
          {engineId === 'frame' ? (
            <p className={mute ? 'lens-warn' : undefined}>
              <b>Simulated:</b> the text itself. In <span className="mono">FRAME</span> mode the
              recognizer substitutes a reference card instead of running OCR.
              {mute
                ? ' Because the camera is live, the overlay words are withheld entirely — the boxes are measured, but nothing has read what is inside them. Switch to OCR to actually read this card.'
                : translating
                  ? ' Switch to OCR to read the card in front of you for real — ' + scanLang(lang).ocr + ' is a ~15 MB model and wants a frozen frame.'
                  : ' Switch to OCR to read the card in front of you for real — English is the light one (eng, ~4 MB) and still wants a frozen frame.'}
            </p>
          ) : (
            <p>
              <b>Live:</b> the text, read by Tesseract on the frozen frame. Freeze before you
              expect a good read — OCR takes 1–3 s per frame.
            </p>
          )}
          {translating ? (
            <p>
              <b>Translation</b> is a {stats.entries}-entry card glossary, not an API:
              it is instant, works offline, and knows 리자몽 is Charizard rather than
              sounding it out as "Rijamong". {stats.fromPrintedPair} entries are confirmed
              against the publisher's own English print of the same card;{' '}
              {stats.needsCheck} still need a native-speaker check and are marked. Unknown
              words are romanized and never get a price.
            </p>
          ) : (
            <p>
              <b>No translation step:</b> the card is already English, so the glossary is
              skipped entirely and no plates are drawn over it — covering an English card
              with its own words helps nobody. What runs is detect → read → match →
              price. Pick <b>日本語</b> or <b>한국어</b> above for a foreign print and the
              overlay comes back.
            </p>
          )}
          {result && result.hint && (
            <p className="lens-warn">
              <b>Language looks wrong:</b> {scanLang(lang).label} is selected but{' '}
              {result.hint.reason}. That is what an English card looks like through a{' '}
              {scanLang(lang).label} reader — switch to <b>English</b>.
            </p>
          )}
          {camErr && (
            <p className="lens-warn">
              {camErr === 'denied' ? 'Camera permission was declined'
                : camErr === 'no-camera' ? 'No camera on this device'
                : camErr === 'no-api' ? 'This browser exposes no camera API'
                : 'The camera could not be opened'} — running against a rendered reference card
              instead. Detection and tracking below are still real.
            </p>
          )}
          {engineErr && <p className="lens-warn">OCR unavailable: {engineErr}. Fell back to FRAME.</p>}
        </div>

        <div className="lens-controls lens-controls-wrap">
          <button className={'chip' + (engineId === 'frame' ? ' on' : '')} onClick={() => setEngineId('frame')}>Frame engine</button>
          <button className={'chip' + (engineId === 'ocr' ? ' on' : '')}
                  onClick={() => { setEngineErr(null); setWorking(true); setEngineId('ocr'); }}>OCR engine</button>
        </div>

        {engineId === 'frame' && (
          <div className="lens-controls lens-controls-wrap">
            {REFERENCE_CARDS.map(rc => (
              <button key={rc.id} className={'chip' + (refId === rc.id ? ' on' : '')}
                      onClick={() => { setRefId(rc.id); sessionRef.current.reset(); }}>
                {rc.label}
              </button>
            ))}
          </div>
        )}

        <div style={{ height: 20 }} />
      </div>
    </div>
  );
}

/* ═══════════ entry point on Shop home ═══════════ */

function ScanEntry({ onOpen }) {
  return (
    <button onClick={onOpen} className="lens-entry">
      <div className="lens-entry-mark"><Icon name="scan" size={22} color="var(--paper)" /></div>
      <div style={{ flex: 1, minWidth: 0, lineHeight: 1.25 }}>
        <div className="kicker" style={{ color: 'var(--accent)', marginBottom: 4 }}>New · Lens</div>
        <div style={{ fontFamily: 'var(--ff-display)', fontWeight: 800, fontSize: 17, letterSpacing: '-0.02em' }}>
          Scan a card to import it
        </div>
        <div className="muted" style={{ fontSize: 11.5, fontFamily: 'var(--ff-mono)', marginTop: 3 }}>
          English by default · 日本語 and 한국어 translated live
        </div>
      </div>
      <Icon name="chevR" size={18} color="var(--ink-3)" sw={2.4} />
    </button>
  );
}

Object.assign(window, { ScanScreen, ScanEntry, drawReferenceScene, fitFontSize });
