/* ───────────────────────────────────────────────
   Happy Guts — barcode scanning
   • camera via getUserMedia (needs https, or localhost)
   • decoding: the browser's own BarcodeDetector where it exists (Chrome on Android),
     otherwise the bundled ZXing reader (vendor/barcode/ — Safari/iOS, Firefox, desktop).
     The ~1 MB reader is only downloaded the first time someone opens the scanner.
   • product lookup: Open Food Facts (free, open data — ODbL), matched by name to the
     researched foods in the guide; manual barcode entry + guide search as fallbacks.
   Exposed: ScanScreen, lookupProduct, matchFoods, getBarcodeDetector
   ─────────────────────────────────────────────── */

const SCAN_FORMATS = ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128', 'itf'];

function loadScriptOnce(src) {
  const existing = document.querySelector(`script[data-hg-src="${src}"]`);
  if (existing) return existing.hgLoaded;
  const s = document.createElement('script');
  s.src = src; s.async = true; s.dataset.hgSrc = src;
  s.hgLoaded = new Promise((resolve, reject) => {
    s.onload = resolve;
    s.onerror = () => { s.remove(); reject(new Error(`Couldn't load ${src}`)); };
  });
  document.head.appendChild(s);
  return s.hgLoaded;
}

let detectorPromise = null;
function getBarcodeDetector() {
  if (!detectorPromise) {
    detectorPromise = (async () => {
      if ('BarcodeDetector' in window) {
        try {
          const supported = await window.BarcodeDetector.getSupportedFormats();
          const formats = SCAN_FORMATS.filter(f => supported.includes(f));
          if (formats.length) return { detector: new window.BarcodeDetector({ formats }), engine: 'native' };
        } catch { /* fall through to the bundled reader */ }
      }
      if (!window.BarcodeDetectionAPI) await loadScriptOnce('vendor/barcode/ponyfill.js');
      const api = window.BarcodeDetectionAPI;
      // serve the WebAssembly from this site (works offline, no CDN), not the library's default jsDelivr URL
      const wasmURL = new URL('vendor/barcode/zxing_reader.wasm', document.baseURI).href;
      await api.prepareZXingModule({
        overrides: { locateFile: (path, prefix) => (path.endsWith('.wasm') ? wasmURL : prefix + path) },
        fireImmediately: true,
      });
      return { detector: new api.BarcodeDetector({ formats: SCAN_FORMATS }), engine: 'zxing' };
    })();
    detectorPromise.catch(() => { detectorPromise = null; });   // allow a retry after a failed download
  }
  return detectorPromise;
}

/* ── camera + detection loop ── */
function cameraProblem(err) {
  const name = err && err.name;
  if (!window.isSecureContext) return 'insecure';
  if (name === 'NotAllowedError' || name === 'SecurityError') return 'denied';
  if (name === 'NotFoundError' || name === 'OverconstrainedError' || name === 'DevicesNotFoundError') return 'nocamera';
  if (name === 'NotReadableError' || name === 'AbortError') return 'busy';
  return 'error';
}

function useBarcodeScanner(videoRef, onDetect) {
  const [phase, setPhase] = React.useState('starting');   // starting | scanning | insecure | denied | nocamera | busy | error
  const [engine, setEngine] = React.useState(null);
  const [torch, setTorch] = React.useState(null);         // null = no torch on this camera
  const [attempt, setAttempt] = React.useState(0);
  const pausedRef = React.useRef(false);
  const lastRef = React.useRef({ code: null, until: 0 });   // don't re-read the barcode just looked up
  const trackRef = React.useRef(null);
  const onDetectRef = React.useRef(onDetect);
  onDetectRef.current = onDetect;

  React.useEffect(() => {
    let stopped = false, stream = null, timer = null;
    const stopCamera = () => { if (stream) stream.getTracks().forEach(t => t.stop()); stream = null; trackRef.current = null; };

    (async () => {
      setPhase('starting');
      if (!window.isSecureContext) { setPhase('insecure'); return; }
      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setPhase('nocamera'); return; }
      const detP = getBarcodeDetector();
      detP.catch(() => {});
      try {
        stream = await navigator.mediaDevices.getUserMedia({
          audio: false,
          video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
        });
        if (stopped) { stopCamera(); return; }
        const video = videoRef.current;
        video.srcObject = stream;
        await video.play().catch(() => {});
        const track = stream.getVideoTracks()[0];
        trackRef.current = track;
        const caps = track && track.getCapabilities ? track.getCapabilities() : {};
        setTorch(caps.torch ? false : null);

        const { detector, engine } = await detP;
        if (stopped) return;
        setEngine(engine);
        setPhase('scanning');
        const tick = async () => {
          if (stopped) return;
          if (!pausedRef.current && video.readyState >= 2 && !document.hidden) {
            try {
              const codes = await detector.detect(video);
              const last = lastRef.current;
              const hit = codes.find(c => c.rawValue && !(c.rawValue === last.code && Date.now() < last.until));
              if (hit && !stopped && !pausedRef.current) {
                pausedRef.current = true;
                lastRef.current = { code: hit.rawValue, until: Infinity };
                if (navigator.vibrate) navigator.vibrate(40);
                onDetectRef.current(hit.rawValue.trim(), hit.format);
              }
            } catch { /* a frame that couldn't be read — keep going */ }
          }
          timer = setTimeout(tick, 120);
        };
        tick();
      } catch (err) {
        console.warn('[Happy Guts] camera/scanner:', err);
        stopCamera();
        if (!stopped) setPhase(err && /load|wasm|WebAssembly/i.test(String(err.message)) ? 'error' : cameraProblem(err));
      }
    })();

    // release the camera while the app is in the background; restart on return
    const onVis = () => { if (document.hidden) { stopped = true; clearTimeout(timer); stopCamera(); } else setAttempt(a => a + 1); };
    document.addEventListener('visibilitychange', onVis);
    return () => { stopped = true; clearTimeout(timer); stopCamera(); document.removeEventListener('visibilitychange', onVis); };
  }, [attempt]);

  const toggleTorch = async () => {
    const t = trackRef.current; if (!t || torch === null) return;
    try { await t.applyConstraints({ advanced: [{ torch: !torch }] }); setTorch(!torch); } catch {}
  };
  return {
    phase, engine, torch, toggleTorch,
    pause: () => { pausedRef.current = true; },
    // after a result, the same code stays ignored for 3 s so the product still in view isn't re-read
    resume: () => { pausedRef.current = false; lastRef.current = { ...lastRef.current, until: Date.now() + 3000 }; },
    retry: () => setAttempt(a => a + 1),
  };
}

/* ── Open Food Facts lookup ──
   Browsers can't set a User-Agent, so the app identifies itself with app_name / app_version
   (as OFF's API docs allow). Reads are rate-limited to ~15/min per IP, plenty for scanning. */
const OFF_FIELDS = ['code', 'product_name', 'product_name_en', 'generic_name', 'generic_name_en', 'brands', 'quantity',
  'categories_tags', 'allergens_tags', 'traces_tags', 'labels_tags', 'ingredients_text', 'ingredients_text_en',
  'image_front_small_url'].join(',');
const lookupCache = new Map();

async function lookupProduct(code) {
  if (lookupCache.has(code)) return lookupCache.get(code);
  const version = (window.HG_BUILD && window.HG_BUILD.version) || 'dev';
  const url = `https://world.openfoodfacts.org/api/v2/product/${encodeURIComponent(code)}.json`
    + `?fields=${OFF_FIELDS}&app_name=HappyGuts&app_version=${encodeURIComponent(version)}`;
  let res;
  try { res = await fetch(url); }
  catch { throw Object.assign(new Error('network'), { kind: navigator.onLine === false ? 'offline' : 'network' }); }
  if (res.status === 429 || res.status === 503) throw Object.assign(new Error('busy'), { kind: 'busy' });
  let body = null;
  try { body = await res.json(); } catch {}
  if (res.status === 404 || (body && body.status === 0)) {
    const out = { found: false, code };
    lookupCache.set(code, out);
    return out;
  }
  if (!res.ok || !body || !body.product) throw Object.assign(new Error(`HTTP ${res.status}`), { kind: 'network' });
  const out = { found: true, code, product: body.product };
  lookupCache.set(code, out);
  return out;
}

/* ── product → guide foods (name matching) ──
   The guide is generic foods ("Tuna - Canned in Brine"), not branded products, so a scan is
   matched on the food's main name — the part before " - " or "(" — which must appear in the
   product's name, generic name or categories. Extra qualifiers that also appear score higher;
   ones that don't cost a little, so "Egg (Chicken) - Fresh" beats "… Fried in Argan Oil". */
const MATCH_STOP = new Set(['and', 'with', 'the', 'for', 'from', 'off', 'per', 'pack', 'brand', 'style', 'food', 'foods',
  'product', 'products', 'based', 'plant', 'beverage', 'beverages', 'organic', 'natural', 'pure', 'premium', 'classic',
  'original', 'range', 'free', 'can', 'jar', 'australian', 'british', 'uk', 'new']);
const MATCH_SYN = { yogurt: 'yoghurt', yoghourt: 'yoghurt', flavored: 'flavoured', flavor: 'flavour', chili: 'chilli',
  chile: 'chilli', catsup: 'ketchup', canola: 'canola', rapeseed: 'canola', aubergine: 'eggplant', courgette: 'zucchini',
  prawns: 'prawn', shrimp: 'prawn', garbanzo: 'chickpea', cilantro: 'coriander', capsicum: 'capsicum', pepper: 'pepper' };
function stemToken(t) {
  t = MATCH_SYN[t] || t;
  if (t.length > 4 && t.endsWith('ies')) t = t.slice(0, -3) + 'y';
  else if (t.length > 4 && /(oes|ches|shes|xes)$/.test(t)) t = t.slice(0, -2);
  else if (t.length > 3 && t.endsWith('s') && !/(ss|us|is)$/.test(t)) t = t.slice(0, -1);
  return MATCH_SYN[t] || t;
}
function matchTokens(text) {
  return String(text || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
    .replace(/[^a-z0-9]+/g, ' ').split(' ')
    .filter(t => t.length > 2 && !MATCH_STOP.has(t) && !/^\d+$/.test(t)).map(stemToken);
}

let foodIndex = null;
function getFoodIndex() {
  if (foodIndex && foodIndex.n === window.FOODS.length) return foodIndex;
  const rows = window.FOODS.map(food => {
    const [head, ...tail] = food.name.split(/\s+-\s+|\(|,/);
    const primary = [...new Set(matchTokens(head))];
    const rest = [...new Set(matchTokens(tail.join(' ')))].filter(t => !primary.includes(t));
    return { food, primary, rest };
  }).filter(r => r.primary.length);
  foodIndex = { n: window.FOODS.length, rows };
  return foodIndex;
}

function matchFoods(product, limit = 3) {
  const weights = new Map();
  const add = (text, w) => matchTokens(text).forEach(t => weights.set(t, Math.max(weights.get(t) || 0, w)));
  const brandTokens = new Set(matchTokens(product.brands));
  (product.categories_tags || []).filter(c => c.startsWith('en:')).forEach(c => add(c.slice(3).replace(/-/g, ' '), 1));
  add(product.generic_name_en || product.generic_name, 2);
  add(product.product_name_en || product.product_name, 3);
  brandTokens.forEach(t => { if (weights.get(t) === 3) weights.delete(t); });   // "Heinz" isn't a food

  const scored = [];
  for (const r of getFoodIndex().rows) {
    if (!r.primary.every(t => weights.has(t))) continue;
    const pw = r.primary.reduce((s, t) => s + weights.get(t), 0);
    const present = r.rest.filter(t => weights.has(t));
    const score = pw + 0.6 * present.reduce((s, t) => s + weights.get(t), 0) - 0.5 * (r.rest.length - present.length);
    const strong = r.primary.some(t => weights.get(t) >= 2);
    if (score >= 0.9) scored.push({ food: r.food, score, strong });
  }
  scored.sort((a, b) => (b.strong - a.strong) || (b.score - a.score) || (a.food.name.length - b.food.name.length));
  return scored.slice(0, limit);
}

/* what the label itself says about gluten (allergen data entered on Open Food Facts) */
function labelGluten(p) {
  const has = (arr, tag) => (arr || []).includes(tag);
  if (has(p.allergens_tags, 'en:gluten')) return { tone: 'avoid', text: 'Label lists gluten as an allergen' };
  if (has(p.labels_tags, 'en:gluten-free') || has(p.labels_tags, 'en:no-gluten')) {
    return { tone: 'safe', text: has(p.traces_tags, 'en:gluten') ? 'Labelled gluten-free (also lists gluten traces)' : 'Labelled gluten-free' };
  }
  if (has(p.traces_tags, 'en:gluten')) return { tone: 'caution', text: 'Label says it may contain gluten' };
  return null;
}

/* ── pieces ── */
function GuideSearch({ sens, go, autoFocus }) {
  const [q, setQ] = React.useState('');
  const query = q.trim().toLowerCase();
  const hits = query.length < 2 ? [] : window.FOODS.filter(f => f.name.toLowerCase().includes(query)).slice(0, 6);
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, background: 'var(--card)', boxShadow: 'var(--shadow-1)',
        borderRadius: 14, padding: '0 14px' }}>
        <window.Icon name="search" size={19} color="var(--ink-3)" />
        <input value={q} onChange={e => setQ(e.target.value)} placeholder="Search the food guide" autoFocus={autoFocus}
          style={{ flex: 1, border: 'none', background: 'transparent', padding: '13px 0', fontSize: 16, color: 'var(--ink)',
            outline: 'none', fontFamily: 'inherit' }} />
      </div>
      {hits.length > 0 && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 10 }}>
          {hits.map(f => <window.FoodRow key={f.id} food={f} sens={sens} go={go} />)}
        </div>
      )}
      {query.length >= 2 && hits.length === 0 && (
        <p style={{ fontSize: 13.5, color: 'var(--ink-3)', marginTop: 10 }}>No foods match "{q.trim()}".</p>
      )}
    </div>
  );
}

const TONE = {
  avoid:   { bg: 'var(--avoid-tint)',   ink: 'var(--avoid-ink)' },
  caution: { bg: 'var(--caution-tint)', ink: 'var(--caution-ink)' },
  safe:    { bg: 'var(--safe-tint)',    ink: 'var(--safe-ink)' },
};

function ProductResult({ state, sens, go, onAddToList, onRetry }) {
  const { code, status } = state;
  if (status === 'loading') {
    return (
      <div style={{ padding: '18px 0 8px', textAlign: 'center' }}>
        <div className="eyebrow">Barcode {code}</div>
        <p style={{ fontSize: 15, color: 'var(--ink-2)', marginTop: 10 }}>Looking it up…</p>
      </div>
    );
  }
  if (status === 'error') {
    const msg = state.kind === 'offline' ? "You're offline — product lookups need a connection."
      : state.kind === 'busy' ? 'The product database is busy right now. Try again in a minute.'
      : "Couldn't reach the product database.";
    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        <div className="eyebrow">Barcode {code}</div>
        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
          <window.Icon name={state.kind === 'offline' ? 'offline' : 'info'} size={22} color="var(--ink-3)" />
          <p style={{ fontSize: 14.5, color: 'var(--ink-2)', flex: 1 }}>{msg}</p>
        </div>
        <button className="btn btn-ghost" onClick={onRetry} style={{ padding: 13 }}>Try again</button>
        <div className="eyebrow" style={{ marginTop: 4 }}>Or find it in the guide</div>
        <GuideSearch sens={sens} go={go} />
      </div>
    );
  }
  if (!state.found) {
    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        <div>
          <div className="eyebrow">Barcode {code}</div>
          <h2 style={{ fontSize: 21, marginTop: 4 }}>Not in the product database yet</h2>
          <p style={{ fontSize: 14, color: 'var(--ink-2)', marginTop: 6, lineHeight: 1.45 }}>
            Search the guide for its main ingredient instead.
          </p>
        </div>
        <GuideSearch sens={sens} go={go} />
        <a href={`https://world.openfoodfacts.org/product/${encodeURIComponent(code)}`} target="_blank" rel="noopener"
          style={{ fontSize: 13, color: 'var(--ink-3)', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
          <window.Icon name="external" size={15} color="var(--ink-3)" /> Add it to Open Food Facts
        </a>
      </div>
    );
  }

  const p = state.product;
  const name = p.product_name_en || p.product_name || p.generic_name_en || p.generic_name || 'Unnamed product';
  const sub = [p.brands && p.brands.split(',')[0].trim(), p.quantity].filter(Boolean).join(' · ');
  const gluten = labelGluten(p);
  const matches = state.matches || [];
  const top = matches[0];
  const ingredients = p.ingredients_text_en || p.ingredients_text;
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
      <div style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
        {p.image_front_small_url && (
          <img src={p.image_front_small_url} alt="" referrerPolicy="no-referrer"
            onError={e => { e.currentTarget.style.display = 'none'; }}
            style={{ width: 58, height: 58, objectFit: 'contain', borderRadius: 14, background: '#fff', flexShrink: 0 }} />
        )}
        <div style={{ flex: 1, minWidth: 0 }}>
          <div className="eyebrow">Barcode {code}</div>
          <h2 style={{ fontSize: 21, marginTop: 3, lineHeight: 1.2 }}>{name}</h2>
          {sub && <div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 2 }}>{sub}</div>}
        </div>
      </div>

      {gluten && (
        <div style={{ background: TONE[gluten.tone].bg, color: TONE[gluten.tone].ink, borderRadius: 14, padding: '11px 14px',
          fontSize: 14, fontWeight: 600, display: 'flex', gap: 9, alignItems: 'center' }}>
          <span style={{ width: 9, height: 9, borderRadius: 99, background: 'var(--c-gluten)', flexShrink: 0 }} />
          {gluten.text}
        </div>
      )}

      {top ? (
        <div>
          <div className="eyebrow" style={{ marginBottom: 8 }}>{matches.length > 1 ? 'Closest foods in the guide' : 'Closest food in the guide'}</div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
            {matches.map(m => <window.FoodRow key={m.food.id} food={m.food} sens={sens} go={go} />)}
          </div>
          <p style={{ fontSize: 13.5, color: 'var(--ink-2)', marginTop: 10, lineHeight: 1.45 }}>
            {top.strong ? '' : 'A loose match from the product category. '}
            {window.verdictExplain(top.food, sens)} Matched by name — check the ingredients for anything else.
          </p>
          <div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
            <button className="btn btn-ghost" style={{ flex: 1, padding: 13 }} onClick={() => go('food', { id: top.food.id })}>Full details</button>
            <button className="btn btn-spark" style={{ flex: 1, padding: 13 }} onClick={() => onAddToList(top.food.id)}>Add to list</button>
          </div>
        </div>
      ) : (
        <p style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.45 }}>
          We couldn't match this to a food in the guide yet. Search for its main ingredient:
        </p>
      )}

      {ingredients && (
        <details style={{ fontSize: 13.5, color: 'var(--ink-2)', lineHeight: 1.5 }}>
          <summary style={{ fontWeight: 700, color: 'var(--ink)', cursor: 'pointer' }}>Ingredients (from the label)</summary>
          <p style={{ marginTop: 6 }}>{ingredients}</p>
        </details>
      )}

      <div>
        {top && <div className="eyebrow" style={{ marginBottom: 8 }}>Not the right food?</div>}
        <GuideSearch sens={sens} go={go} />
      </div>

      <p style={{ fontSize: 11.5, color: 'var(--ink-3)' }}>
        Product data from <a href={`https://world.openfoodfacts.org/product/${encodeURIComponent(code)}`} target="_blank" rel="noopener"
          style={{ color: 'inherit' }}>Open Food Facts</a> (ODbL) — community-entered, so always check the pack.
      </p>
    </div>
  );
}

const PROBLEM_TEXT = {
  insecure: ['Camera needs a secure connection', 'Open Happy Guts from its https:// address (or the installed app) to scan. You can still type a barcode.'],
  denied:   ['Camera access is off', 'Allow camera access for this site in your browser settings, then try again — or type the barcode.'],
  nocamera: ['No camera found', "This device doesn't have a camera we can use. You can type the barcode instead."],
  busy:     ['Camera is busy', 'Another app may be using the camera. Close it and try again.'],
  error:    ["Scanner couldn't start", 'Check your connection the first time you scan (the barcode reader is downloaded once), then try again.'],
};

/* ── SCAN screen ── */
function ScanScreen({ sens, go, onAddToList }) {
  const videoRef = React.useRef(null);
  const [result, setResult] = React.useState(null);    // { code, status: loading|done|error, found, product, matches, kind }
  const [typing, setTyping] = React.useState(false);
  const [typed, setTyped] = React.useState('');
  const [added, setAdded] = React.useState(false);

  const lookup = async (code) => {
    setResult({ code, status: 'loading' });
    try {
      const r = await lookupProduct(code);
      setResult({ ...r, status: 'done', matches: r.found ? matchFoods(r.product) : [] });
    } catch (e) {
      setResult({ code, status: 'error', kind: e.kind || 'network' });
    }
  };
  const scanner = useBarcodeScanner(videoRef, (code) => lookup(code));
  const closeResult = () => { setResult(null); setAdded(false); scanner.resume(); };
  const submitTyped = (e) => {
    e.preventDefault();
    const code = typed.replace(/\D/g, '');
    if (code.length < 6) return;
    setTyping(false); setTyped(''); scanner.pause(); lookup(code);
  };
  const addToList = (id) => { onAddToList(id); setAdded(true); };

  const problem = PROBLEM_TEXT[scanner.phase];
  const status = scanner.phase === 'starting' ? 'Starting camera…' : 'Point at a product barcode';

  return (
    <div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#14110f', position: 'relative' }}>
      <window.TopBar title="" onBack={() => go('home')} right={
        <span style={{ color: 'rgba(255,255,255,.8)', fontWeight: 700, fontSize: 15 }}>Scan</span>
      } />
      {/* viewfinder */}
      <div style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center',
        margin: '0 20px', borderRadius: 28, overflow: 'hidden', background: '#1b1613' }}>
        <video ref={videoRef} playsInline muted autoPlay data-engine={scanner.engine || ''} data-phase={scanner.phase}
          style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover',
            opacity: scanner.phase === 'scanning' ? 1 : 0, transition: 'opacity .25s' }} />
        {problem ? (
          <div style={{ position: 'relative', padding: 28, textAlign: 'center', maxWidth: 320 }}>
            <window.Icon name="camera" size={34} color="rgba(255,255,255,.6)" />
            <div style={{ color: '#fff', fontWeight: 700, fontSize: 17, marginTop: 12 }}>{problem[0]}</div>
            <p style={{ color: 'rgba(255,255,255,.65)', fontSize: 14, lineHeight: 1.45, marginTop: 8 }}>{problem[1]}</p>
            {scanner.phase !== 'insecure' && scanner.phase !== 'nocamera' && (
              <button className="btn btn-ghost" onClick={scanner.retry} style={{ marginTop: 16, padding: '10px 18px' }}>Try again</button>
            )}
          </div>
        ) : (
          <React.Fragment>
            <div style={{ position: 'absolute', top: 22, left: 0, right: 0, textAlign: 'center', zIndex: 1,
              color: '#fff', fontSize: 14, fontWeight: 600, textShadow: '0 1px 6px rgba(0,0,0,.6)' }}>{status}</div>
            <div style={{ width: 240, height: 150, position: 'relative' }}>
              {[[0, 0], [1, 0], [0, 1], [1, 1]].map(([x, y], k) => (
                <span key={k} style={{ position: 'absolute', width: 30, height: 30,
                  [x ? 'right' : 'left']: 0, [y ? 'bottom' : 'top']: 0,
                  borderTop: y ? 'none' : '4px solid var(--spark)', borderBottom: y ? '4px solid var(--spark)' : 'none',
                  borderLeft: x ? 'none' : '4px solid var(--spark)', borderRight: x ? '4px solid var(--spark)' : 'none',
                  borderRadius: 6 }} />
              ))}
              {scanner.phase === 'scanning' && !result && (
                <div style={{ position: 'absolute', left: 6, right: 6, height: 3, background: 'var(--spark)',
                  boxShadow: '0 0 12px var(--spark)', borderRadius: 9, animation: 'hgScan 1.6s ease-in-out infinite' }} />
              )}
            </div>
          </React.Fragment>
        )}
      </div>

      {/* controls */}
      <div style={{ padding: '18px 20px calc(14px + env(safe-area-inset-bottom, 0px))', display: 'flex', gap: 10 }}>
        <button className="btn btn-spark" onClick={() => { setTyping(true); scanner.pause(); }} style={{ flex: 1, padding: 15, fontSize: 16 }}>
          <window.Icon name="keyboard" size={20} color="#fff" /> Type barcode
        </button>
        {scanner.torch !== null && (
          <button className="btn" onClick={scanner.toggleTorch} aria-label="Torch" style={{ width: 54,
            background: scanner.torch ? 'var(--spark)' : 'rgba(255,255,255,.12)' }}>
            <window.Icon name="flash" size={22} color="#fff" />
          </button>
        )}
      </div>

      <window.Sheet open={typing} onClose={() => { setTyping(false); scanner.resume(); }} title="Type a barcode">
        <form onSubmit={submitTyped} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <input value={typed} onChange={e => setTyped(e.target.value)} inputMode="numeric" autoComplete="off" autoFocus
            placeholder="e.g. 9300633603116" aria-label="Barcode number"
            style={{ border: 'none', background: 'var(--card)', boxShadow: 'var(--shadow-1)', borderRadius: 14,
              padding: '14px 16px', fontSize: 18, letterSpacing: 1, fontFamily: 'var(--font-mono)', color: 'var(--ink)', outline: 'none' }} />
          <button type="submit" className="btn btn-spark btn-block" disabled={typed.replace(/\D/g, '').length < 6}
            style={{ opacity: typed.replace(/\D/g, '').length < 6 ? 0.5 : 1 }}>Look up</button>
        </form>
      </window.Sheet>

      <window.Sheet open={!!result} onClose={closeResult}>
        {result && <ProductResult state={result} sens={sens} go={go} onAddToList={addToList} onRetry={() => lookup(result.code)} />}
        {added && <p style={{ fontSize: 13.5, color: 'var(--safe-ink)', fontWeight: 700, marginTop: 12 }}>Added to your list ✓</p>}
        {result && result.status !== 'loading' && (
          <button className="btn btn-ghost btn-block" onClick={closeResult} style={{ marginTop: 16 }}>
            <window.Icon name="scan" size={20} /> Scan another
          </button>
        )}
      </window.Sheet>
    </div>
  );
}

Object.assign(window, { ScanScreen, lookupProduct, matchFoods, getBarcodeDetector });
