// Shared primitives. Uses Tailwind via CDN.

function Card({ children, className = '', ...rest }) {
  return (
    <div className={`bg-white border border-neutral-200 rounded-xl ${className}`} {...rest}>
      {children}
    </div>
  );
}

function CardHeader({ title, subtitle }) {
  return (
    <div className="px-5 pt-5 pb-3">
      <div className="text-[15px] font-semibold tracking-tight text-neutral-900 leading-snug">{title}</div>
      {subtitle && <div className="text-[13px] text-neutral-500 mt-0.5 leading-snug">{subtitle}</div>}
    </div>
  );
}

function ContactLinks() {
  return (
    <div className="flex flex-wrap gap-2">
      <a href="mailto:sales@smith3d.com"
        className="inline-flex items-center gap-1.5 h-9 pl-2.5 pr-3 rounded-full border border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50 transition text-[12.5px] font-medium text-neutral-800 active:scale-[0.98]">
        <span className="w-5 h-5 rounded-full bg-neutral-100 grid place-items-center">
          <IconMail size={12} className="text-neutral-600"/>
        </span>
        sales@smith3d.com
      </a>
      <a href="https://api.whatsapp.com/send/?phone=60103443128&text&type=phone_number&app_absent=0"
        target="_blank" rel="noopener noreferrer"
        className="inline-flex items-center gap-1.5 h-9 pl-2.5 pr-3 rounded-full border border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50 transition text-[12.5px] font-medium text-neutral-800 active:scale-[0.98]">
        <span className="w-5 h-5 rounded-full bg-neutral-100 grid place-items-center">
          <IconWhatsapp size={12} className="text-[#25D366]"/>
        </span>
        WhatsApp us
      </a>
    </div>
  );
}

function Button({ variant = 'primary', size = 'md', className = '', children, disabled, ...rest }) {
  const variants = {
    primary: 'bg-[var(--s3-orange)] hover:bg-[var(--s3-orange-600)] text-white shadow-sm shadow-orange-900/10',
    secondary: 'bg-white border border-neutral-300 hover:border-neutral-400 text-neutral-900',
    ghost: 'hover:bg-neutral-100 text-neutral-700',
    subtle: 'bg-neutral-100 hover:bg-neutral-200 text-neutral-800',
  };
  const sizes = {
    sm: 'h-8 px-3 text-[13px]',
    md: 'h-10 px-4 text-[14px]',
    lg: 'h-12 px-5 text-[15px]',
    xl: 'h-14 px-6 text-[16px]',
  };
  return (
    <button
      disabled={disabled}
      className={`inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all
        disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none focus:outline-none
        focus-visible:ring-2 focus-visible:ring-[var(--s3-orange)]/40 active:scale-[0.98]
        ${variants[variant]} ${sizes[size]} ${className}`}
      {...rest}
    >{children}</button>
  );
}

function Label({ children, htmlFor, required, hint }) {
  return (
    <label htmlFor={htmlFor} className="block text-[13px] font-medium text-neutral-800 mb-1.5">
      {children}
      {required && <span className="text-[var(--s3-orange)] ml-0.5">*</span>}
      {hint && <span className="ml-2 font-normal text-neutral-400">{hint}</span>}
    </label>
  );
}

function Input({ mono, className = '', invalid, ...rest }) {
  return (
    <input
      className={`w-full h-11 px-3 rounded-lg border bg-white
        ${invalid ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : 'border-neutral-300 focus:border-[var(--s3-orange)] focus:ring-orange-200'}
        focus:ring-4 focus:outline-none
        text-[15px] text-neutral-900 placeholder:text-neutral-400
        transition-colors
        ${mono ? 'font-mono tracking-wider' : ''}
        ${className}`}
      {...rest}
    />
  );
}

function Select({ className = '', children, ...rest }) {
  return (
    <div className="relative">
      <select
        className={`w-full h-11 px-3 pr-9 rounded-lg border border-neutral-300 bg-white appearance-none
          focus:border-[var(--s3-orange)] focus:ring-4 focus:ring-orange-200 focus:outline-none
          text-[15px] text-neutral-900 ${className}`}
        {...rest}
      >{children}</select>
      <IconChevronDown size={16} className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 pointer-events-none"/>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Photo capture tile — stores the underlying File object so the
// form submit can append it to FormData.
// ─────────────────────────────────────────────────────────────
const MAX_PHOTOS = 5;
let _photoUid = 0;

function PhotoTile({ accept = 'image/*', capture = 'environment', label, helper, max = MAX_PHOTOS, onChange }) {
  const [items, setItems] = React.useState([]);
  const [expandedIdx, setExpandedIdx] = React.useState(null);
  const inputRef = React.useRef(null);

  React.useEffect(() => {
    const done = items.filter(i => i.state === 'done');
    onChange?.(done.length ? done : null);
  }, [items]);

  const handleFiles = (fileList) => {
    const files = Array.from(fileList || []);
    if (!files.length) return;
    const room = max - items.length;
    const accepted = files.slice(0, room);
    const fresh = accepted.map(f => ({
      id: ++_photoUid,
      file: f,
      name: f.name, size: f.size, mime: f.type,
      preview: URL.createObjectURL(f),
      progress: 0, state: 'uploading',
    }));
    setItems(prev => [...prev, ...fresh]);
    fresh.forEach(fi => simulateReady(fi.id));
  };

  // We don't actually upload yet — the file goes along with the form submit.
  // Run a short "readying" animation so the UX matches the design.
  const simulateReady = (id) => {
    let p = 0;
    const tick = () => {
      p = Math.min(100, p + 22 + Math.random() * 18);
      setItems(prev => prev.map(it => it.id === id
        ? { ...it, progress: Math.round(p), state: p >= 100 ? 'done' : 'uploading' }
        : it));
      if (p < 100) setTimeout(tick, 80);
    };
    setTimeout(tick, 80);
  };

  const removeItem = (id) => {
    setItems(prev => prev.filter(it => it.id !== id));
  };

  const fmtSize = (n) => n > 1e6 ? `${(n/1e6).toFixed(1)} MB` : `${Math.round(n/1e3)} KB`;
  const truncate = (s, n=22) => s.length > n ? s.slice(0, n-3) + '...' : s;
  const canAddMore = items.length < max;
  const doneCount = items.filter(i => i.state === 'done').length;

  return (
    <>
      <input ref={inputRef} type="file" accept={accept} capture={capture} multiple className="hidden"
        onChange={(e) => { handleFiles(e.target.files); if (inputRef.current) inputRef.current.value = ''; }}/>

      {items.length === 0 ? (
        <button type="button"
          onClick={() => inputRef.current?.click()}
          className="group w-full rounded-xl border-[1.5px] border-dashed border-neutral-300 hover:border-[var(--s3-orange)] hover:bg-orange-50/40
            transition-colors p-5 flex flex-col items-center gap-2 text-center">
          <div className="w-11 h-11 rounded-full bg-neutral-100 group-hover:bg-[var(--s3-orange)]/10 grid place-items-center transition-colors">
            <IconCamera size={20} className="text-neutral-500 group-hover:text-[var(--s3-orange)] transition-colors"/>
          </div>
          <div className="text-[14px] font-medium text-neutral-800">{label}</div>
          {helper && <div className="text-[12px] text-neutral-500">{helper}</div>}
          <div className="text-[11px] text-neutral-400 mt-0.5">Up to {max} · tap to add multiple</div>
        </button>
      ) : (
        <div className="space-y-2">
          <div className="grid grid-cols-3 gap-2">
            {items.map((it, idx) => (
              <div key={it.id} className="relative rounded-lg overflow-hidden border border-neutral-200 bg-neutral-100 aspect-square group">
                <button type="button" className="block w-full h-full"
                  onClick={() => it.state === 'done' && setExpandedIdx(idx)}>
                  {it.mime && it.mime.startsWith('image/') ? (
                    <img src={it.preview} alt="" className="w-full h-full object-cover"/>
                  ) : (
                    <div className="w-full h-full bg-gradient-to-br from-neutral-50 to-neutral-200 grid place-items-center">
                      <IconFileText size={28} className="text-neutral-400"/>
                    </div>
                  )}
                </button>

                {it.state === 'uploading' && (
                  <>
                    <div className="absolute inset-0 bg-black/30 grid place-items-center">
                      <svg className="animate-spin text-white" width="20" height="20" viewBox="0 0 24 24" fill="none">
                        <circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.3" strokeWidth="3"/>
                        <path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
                      </svg>
                    </div>
                    <div className="absolute inset-x-0 bottom-0 h-1 bg-black/30">
                      <div className="h-full bg-[var(--s3-orange)] transition-all" style={{ width: `${it.progress}%` }}/>
                    </div>
                  </>
                )}

                <button type="button"
                  onClick={(e) => { e.stopPropagation(); removeItem(it.id); }}
                  className="absolute top-1 right-1 w-6 h-6 rounded-full bg-black/65 backdrop-blur text-white grid place-items-center hover:bg-black/85">
                  <IconX size={12} sw={2.8}/>
                </button>

                <div className="absolute bottom-1 left-1 text-[9.5px] font-mono font-semibold px-1.5 py-0.5 rounded bg-black/60 text-white backdrop-blur">
                  {idx + 1}
                </div>
              </div>
            ))}

            {canAddMore && (
              <button type="button"
                onClick={() => inputRef.current?.click()}
                className="rounded-lg border-[1.5px] border-dashed border-neutral-300 hover:border-[var(--s3-orange)] hover:bg-orange-50/40
                  transition-colors aspect-square flex flex-col items-center justify-center gap-1 text-neutral-500 hover:text-[var(--s3-orange)]">
                <IconCamera size={18}/>
                <span className="text-[11px] font-medium">Add more</span>
              </button>
            )}
          </div>

          <div className="flex items-center justify-between px-1 text-[11.5px] text-neutral-500">
            <span>
              {doneCount === items.length
                ? `${doneCount} of ${max} photo${doneCount === 1 ? '' : 's'}`
                : `${doneCount}/${items.length} uploaded`}
            </span>
            {!canAddMore && <span className="text-neutral-400">Max {max} reached</span>}
          </div>
        </div>
      )}

      {expandedIdx !== null && items[expandedIdx] && (
        <div className="fixed inset-0 z-50 bg-black/90 grid place-items-center p-6" onClick={() => setExpandedIdx(null)}>
          {items[expandedIdx].mime?.startsWith('image/') ? (
            <img src={items[expandedIdx].preview} className="max-w-full max-h-full object-contain rounded-lg" alt=""/>
          ) : (
            <div className="bg-white rounded-lg p-10 max-w-md text-neutral-800 text-center">
              <IconFileText size={48} className="text-neutral-400 mx-auto"/>
              <div className="mt-3 font-medium">{items[expandedIdx].name}</div>
              <a href={items[expandedIdx].preview} target="_blank" rel="noopener noreferrer"
                className="inline-block mt-3 text-[13px] text-[var(--s3-orange)] underline">Open file</a>
            </div>
          )}
          {expandedIdx > 0 && (
            <button onClick={(e) => { e.stopPropagation(); setExpandedIdx(i => i - 1); }}
              className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/10 text-white grid place-items-center hover:bg-white/20">
              <IconChevronLeft size={22}/>
            </button>
          )}
          {expandedIdx < items.length - 1 && (
            <button onClick={(e) => { e.stopPropagation(); setExpandedIdx(i => i + 1); }}
              className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/10 text-white grid place-items-center hover:bg-white/20">
              <IconChevronRight size={22}/>
            </button>
          )}
          <button onClick={() => setExpandedIdx(null)}
            className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/10 text-white grid place-items-center">
            <IconX size={20}/>
          </button>
          <div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/60 text-white text-[12px] font-medium">
            <span className="font-mono">{expandedIdx + 1} / {items.length}</span>
            <span className="text-white/50">·</span>
            <span className="truncate max-w-[180px]">{truncate(items[expandedIdx].name, 28)}</span>
            <span className="text-white/50">·</span>
            <span>{fmtSize(items[expandedIdx].size)}</span>
          </div>
        </div>
      )}
    </>
  );
}

// ─────────────────────────────────────────────────────────────
// Scanner modal — live camera barcode scanner.
//   - Prefers native BarcodeDetector (Android Chrome / Edge).
//   - Falls back to ZXing UMD from CDN (iOS Safari, older browsers).
//   - Calls onDetect(serialString) once, then closes.
// Whatever the decoder reads, we accept. The user can still edit the SN
// field if the scan picked up the wrong code (e.g. a wifi QR on the sticker).
// ─────────────────────────────────────────────────────────────

function loadZXing() {
  if (window.ZXing?.BrowserMultiFormatReader) return Promise.resolve(window.ZXing);
  if (window.__zxPromise) return window.__zxPromise;
  window.__zxPromise = new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = 'https://unpkg.com/@zxing/library@0.20.0/umd/index.min.js';
    s.async = true;
    s.onload = () => window.ZXing ? resolve(window.ZXing) : reject(new Error('zxing not loaded'));
    s.onerror = () => reject(new Error('zxing script failed to load'));
    document.head.appendChild(s);
  });
  return window.__zxPromise;
}

function ScannerModal({ open, onClose, onDetect }) {
  const videoRef = React.useRef(null);
  const streamRef = React.useRef(null);
  const stoppedRef = React.useRef(false);
  const guideRef = React.useRef(null); // the on-screen aim box; used to compute which region of each video frame to decode
  const pausedRef = React.useRef(false); // true while showing confirmation — pauses decoding
  const resetCandidateRef = React.useRef(null); // lets "Scan again" clear the 2-in-a-row guard state

  const [torch, setTorch] = React.useState(false);
  const [torchSupported, setTorchSupported] = React.useState(false);
  const [flashGreen, setFlashGreen] = React.useState(false);
  const [scanline, setScanline] = React.useState(0);
  const [status, setStatus] = React.useState('starting'); // starting | scanning | error
  const [error, setError] = React.useState('');
  const [engine, setEngine] = React.useState(''); // 'native' | 'zxing' | ''
  const [lastSeen, setLastSeen] = React.useState(''); // raw text of the most recent detection
  const [confirmingText, setConfirmingText] = React.useState(''); // decoded text awaiting user confirmation
  const [logs, setLogs] = React.useState([]); // on-screen debug log
  const [showLogs, setShowLogs] = React.useState(true);
  const detectCountRef = React.useRef(0);

  const dlog = React.useCallback((...args) => {
    const msg = args.map(a => {
      if (a instanceof Error) return `${a.name}: ${a.message}`;
      if (typeof a === 'object') { try { return JSON.stringify(a); } catch { return String(a); } }
      return String(a);
    }).join(' ');
    const ts = new Date().toISOString().slice(11, 23);
    try { console.log('[scanner]', ...args); } catch {}
    setLogs(prev => [...prev.slice(-19), `${ts} ${msg}`]);
  }, []);

  // Scanline animation
  React.useEffect(() => {
    if (!open) return;
    let raf;
    const start = performance.now();
    const tick = (t) => {
      setScanline(((t - start) / 1800) % 1);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [open]);

  // Camera + detection lifecycle
  React.useEffect(() => {
    if (!open) return;
    stoppedRef.current = false;
    setError('');
    setStatus('starting');
    setTorch(false);
    setTorchSupported(false);
    setFlashGreen(false);
    setLogs([]);
    setLastSeen('');
    setEngine('');
    setConfirmingText('');
    pausedRef.current = false;
    detectCountRef.current = 0;
    dlog('open: ua=', navigator.userAgent.slice(0, 80));
    dlog('BarcodeDetector=', 'BarcodeDetector' in window, 'mediaDevices=', !!navigator.mediaDevices);

    let rafId = 0;

    // Phantom-decode guard: require the SAME text to be seen twice in a row
    // before accepting. Single-frame reads off patterns/moiré/shadows won't
    // repeat; a real barcode held in frame decodes identically tick-to-tick.
    let lastCandidate = '';
    let candidateHits = 0;

    // "Scan again" clears the guard state so the next read starts fresh —
    // otherwise, pointing at the same barcode would insta-accept.
    resetCandidateRef.current = () => { lastCandidate = ''; candidateHits = 0; };

    const handleResult = (raw) => {
      if (stoppedRef.current || pausedRef.current) return;
      const text = String(raw || '').trim();
      if (!text) return;
      detectCountRef.current += 1;
      setLastSeen(text.slice(0, 64));

      // Phantoms from blurry frames decode to tiny fragments like "C0B" or
      // "x4". Real product barcodes are always 6+ chars. Reject anything
      // shorter outright — it's noise, not a successful scan.
      if (text.length < 6) {
        dlog(`reject too-short (${text.length}): "${text}"`);
        return;
      }

      if (text === lastCandidate) {
        candidateHits += 1;
      } else {
        lastCandidate = text;
        candidateHits = 1;
      }
      dlog(`detect #${detectCountRef.current} (hit ${candidateHits}/2): "${text}"`);
      if (candidateHits < 2) return; // wait for a second matching read

      // Pause decoding and show the confirmation panel. The user reviews the
      // value and taps Confirm (which commits + closes) or Scan again
      // (which resumes decoding). We do NOT stop the camera or tear the
      // modal down yet — the user may need to re-aim.
      pausedRef.current = true;
      setConfirmingText(text);
      setFlashGreen(true);
      try { navigator.vibrate && navigator.vibrate(80); } catch {}
    };

    (async () => {
      if (!navigator.mediaDevices?.getUserMedia) {
        dlog('FATAL: no getUserMedia');
        setError('Camera not available in this browser — please type the SN manually.');
        setStatus('error');
        return;
      }
      let stream;
      try {
        dlog('requesting camera…');
        stream = await navigator.mediaDevices.getUserMedia({
          video: {
            facingMode: { ideal: 'environment' },
            width: { ideal: 1920 },
            height: { ideal: 1080 },
          },
          audio: false,
        });
        dlog('camera OK:', stream.getVideoTracks()[0]?.label || '(no label)');
      } catch (e) {
        dlog('getUserMedia failed:', e);
        const msg =
          e?.name === 'NotAllowedError' ? 'Camera permission denied. Allow camera access or type the SN manually.'
          : e?.name === 'NotFoundError' ? 'No camera found on this device.'
          : e?.name === 'NotReadableError' ? 'Camera is busy — close other apps and try again.'
          : 'Could not start camera. Please type the SN manually.';
        setError(msg);
        setStatus('error');
        return;
      }
      if (stoppedRef.current) { stream.getTracks().forEach(t => t.stop()); return; }
      streamRef.current = stream;

      const track = stream.getVideoTracks()[0];
      try {
        const caps = track.getCapabilities?.() || {};
        dlog('track caps:', { torch: !!caps.torch, focusMode: caps.focusMode, zoom: caps.zoom, w: caps.width?.max, h: caps.height?.max });
        if (caps.torch) setTorchSupported(true);
        // Request continuous autofocus + modest zoom — dramatically improves
        // small-sticker barcode decode on modern phones.
        const advanced = [];
        if (Array.isArray(caps.focusMode) && caps.focusMode.includes('continuous')) {
          advanced.push({ focusMode: 'continuous' });
        }
        if (caps.zoom && typeof caps.zoom.min === 'number' && typeof caps.zoom.max === 'number') {
          const z = Math.min(2, Math.max(caps.zoom.min, Math.min(caps.zoom.max, 1.6)));
          advanced.push({ zoom: z });
        }
        if (advanced.length) {
          try { await track.applyConstraints({ advanced }); dlog('applied constraints:', advanced); }
          catch (ce) { dlog('applyConstraints failed:', ce); }
        }
      } catch (e) { dlog('getCapabilities failed:', e); }

      const video = videoRef.current;
      if (!video) { dlog('FATAL: no video element'); return; }
      video.setAttribute('playsinline', 'true');
      video.muted = true;
      video.srcObject = stream;
      try { await video.play(); } catch (pe) { dlog('video.play failed', pe); }
      setStatus('scanning');

      // Helpers: threshold + nearest-neighbor upscale. See earlier research
      // in scanner-test.html — on the 0.20.0 UMD, the HybridBinarizer fails
      // to binarize anti-aliased camera frames reliably; adaptive-mean B/W
      // threshold on the aim crop decodes much more consistently.
      const thresholdCanvas = (src) => {
        const cv = document.createElement('canvas');
        cv.width = src.width; cv.height = src.height;
        const cx = cv.getContext('2d');
        cx.drawImage(src, 0, 0);
        const img = cx.getImageData(0, 0, cv.width, cv.height);
        const d = img.data;
        let sum = 0;
        const N = d.length / 4;
        for (let i = 0; i < d.length; i += 4) sum += (d[i] * 0.299 + d[i+1] * 0.587 + d[i+2] * 0.114);
        const T = Math.max(60, Math.min(200, (sum / N) | 0));
        for (let i = 0; i < d.length; i += 4) {
          const g = (d[i] * 0.299 + d[i+1] * 0.587 + d[i+2] * 0.114);
          const v = g < T ? 0 : 255;
          d[i] = v; d[i+1] = v; d[i+2] = v;
        }
        cx.putImageData(img, 0, 0);
        return cv;
      };
      const upscaleCanvas = (src, scale) => {
        const cv = document.createElement('canvas');
        cv.width = Math.round(src.width * scale);
        cv.height = Math.round(src.height * scale);
        const cx = cv.getContext('2d');
        cx.imageSmoothingEnabled = false;
        cx.drawImage(src, 0, 0, cv.width, cv.height);
        return cv;
      };

      // Load ZXing + build the low-level MultiFormatReader used for per-tick
      // decodes. We go around BrowserMultiFormatReader.decodeFromImageUrl
      // because it's broken in the 0.20.0 UMD; direct MultiFormatReader +
      // HTMLCanvasElementLuminanceSource is the reliable path.
      let ZX = null;
      try {
        dlog('loading zxing…');
        ZX = await loadZXing();
        if (stoppedRef.current) return;
        if (!ZX?.MultiFormatReader) throw new Error('ZXing.MultiFormatReader missing');
      } catch (e) {
        dlog('zxing load failed:', e);
        setError('Could not load the barcode scanner. Please type the SN manually.');
        setStatus('error');
        return;
      }

      // Format whitelist: Code-128 / Code-39 / QR / DataMatrix — these cover
      // printer SNs. We deliberately OMIT EAN/ITF/Codabar because they
      // false-positive on nearby box-EAN barcodes (verified with the real
      // Bambu label: EAN-13 misread as a structurally-valid but wrong code).
      const hints = new Map();
      hints.set(ZX.DecodeHintType.POSSIBLE_FORMATS, [
        ZX.BarcodeFormat.CODE_128,
        ZX.BarcodeFormat.CODE_39,
        ZX.BarcodeFormat.QR_CODE,
        ZX.BarcodeFormat.DATA_MATRIX,
      ]);
      hints.set(ZX.DecodeHintType.TRY_HARDER, true);
      const mfReader = new ZX.MultiFormatReader();
      try { mfReader.setHints(hints); } catch {}
      setEngine('zxing');

      const decodeCanvas = (cv) => {
        try {
          const lum = new ZX.HTMLCanvasElementLuminanceSource(cv);
          const bin = new ZX.BinaryBitmap(new ZX.HybridBinarizer(lum));
          const r = mfReader.decode(bin);
          mfReader.reset();
          return r ? r.getText() : null;
        } catch (e) {
          mfReader.reset();
          return null;
        }
      };

      // Compute the on-screen aim box as 0..1 relative coords within the
      // video's native frame. The <video> uses object-cover so we back out
      // its cropping to get an exact mapping from the orange guide rect →
      // source frame coords. Re-measured every tick because the guide rect
      // can change on orientation/layout changes.
      const computeAimRelative = () => {
        const v = videoRef.current;
        const g = guideRef.current;
        if (!v || !g) return null;
        const vw = v.videoWidth, vh = v.videoHeight;
        if (!vw || !vh) return null;
        const vRect = v.getBoundingClientRect();
        const gRect = g.getBoundingClientRect();
        if (!vRect.width || !vRect.height) return null;
        const containerAspect = vRect.width / vRect.height;
        const contentAspect = vw / vh;
        let scale, offX = 0, offY = 0;
        if (contentAspect > containerAspect) {
          scale = vRect.height / vh;
          offX = (vw * scale - vRect.width) / 2;
        } else {
          scale = vRect.width / vw;
          offY = (vh * scale - vRect.height) / 2;
        }
        const x = (gRect.left - vRect.left + offX) / scale;
        const y = (gRect.top - vRect.top + offY) / scale;
        const w = gRect.width / scale;
        const h = gRect.height / scale;
        return {
          rx: Math.max(0, Math.min(1, x / vw)),
          ry: Math.max(0, Math.min(1, y / vh)),
          rw: Math.max(0, Math.min(1, w / vw)),
          rh: Math.max(0, Math.min(1, h / vh)),
        };
      };

      // Reusable offscreen canvas for per-tick frame grabs, to avoid
      // allocating a new canvas 6+ times/second.
      const frameCanvas = document.createElement('canvas');
      const cropCanvas = document.createElement('canvas');

      // Continuous scan loop — fires as fast as decodes finish, naturally
      // rate-limited by decode latency. Each tick:
      //   1. Draw current <video> frame to an offscreen canvas
      //   2. Crop to the on-screen aim rect (object-cover aware)
      //   3. Try decode: raw → threshold → 3x+threshold
      //   4. On hit, require 2 consecutive matches (handleResult) before
      //      committing — prevents single-frame noise from firing a wrong SN
      const scanLoop = async () => {
        while (!stoppedRef.current) {
          // Paused during confirmation — camera stays live (so user can
          // re-aim), we just don't burn CPU decoding unseen frames.
          if (pausedRef.current) { await new Promise(r => setTimeout(r, 80)); continue; }
          try {
            const v = videoRef.current;
            if (v && v.readyState >= 2 && v.videoWidth) {
              const aim = computeAimRelative();
              if (aim && aim.rw > 0.01 && aim.rh > 0.01) {
                const vw = v.videoWidth, vh = v.videoHeight;
                // Grab whole frame once (video → frameCanvas)
                if (frameCanvas.width !== vw) frameCanvas.width = vw;
                if (frameCanvas.height !== vh) frameCanvas.height = vh;
                frameCanvas.getContext('2d').drawImage(v, 0, 0, vw, vh);
                // Crop the aim rect out of that frame (frameCanvas → cropCanvas)
                const cx0 = Math.max(0, Math.round(aim.rx * vw));
                const cy0 = Math.max(0, Math.round(aim.ry * vh));
                const cw = Math.max(1, Math.round(aim.rw * vw));
                const ch = Math.max(1, Math.round(aim.rh * vh));
                if (cropCanvas.width !== cw) cropCanvas.width = cw;
                if (cropCanvas.height !== ch) cropCanvas.height = ch;
                cropCanvas.getContext('2d').drawImage(frameCanvas, cx0, cy0, cw, ch, 0, 0, cw, ch);

                // Per-tick decode chain: cheapest first. The moment one
                // variant returns text, handleResult is called. If the user
                // is holding the phone steady on the right barcode, one of
                // these will hit within a frame or two.
                let text = decodeCanvas(cropCanvas);
                if (!text) text = decodeCanvas(thresholdCanvas(cropCanvas));
                if (!text) text = decodeCanvas(thresholdCanvas(upscaleCanvas(cropCanvas, 3)));
                if (text) handleResult(text);
              }
            }
          } catch (e) {
            // swallow — normal NotFound paths are silent (decodeCanvas
            // already returns null on them). Anything else we just retry.
          }
          // Yield to the browser so the video keeps repainting and React
          // can schedule work. 30ms ≈ 33 fps cap; real decode work will push
          // this lower on most phones.
          await new Promise(r => setTimeout(r, 30));
        }
      };
      scanLoop();
    })();

    return () => {
      stoppedRef.current = true;
      resetCandidateRef.current = null;
      if (rafId) cancelAnimationFrame(rafId);
      if (streamRef.current) {
        streamRef.current.getTracks().forEach(t => { try { t.stop(); } catch {} });
        streamRef.current = null;
      }
      if (videoRef.current) {
        try { videoRef.current.srcObject = null; } catch {}
      }
    };
  }, [open]);

  const confirmScan = React.useCallback(() => {
    if (!confirmingText) return;
    const text = confirmingText;
    // Tear down immediately on confirm so the camera releases
    stoppedRef.current = true;
    onDetect?.(text);
    onClose?.();
  }, [confirmingText, onDetect, onClose]);

  const rescan = React.useCallback(() => {
    setConfirmingText('');
    setFlashGreen(false);
    resetCandidateRef.current?.();
    pausedRef.current = false;
  }, []);

  // Torch toggle
  React.useEffect(() => {
    if (!open || !streamRef.current || !torchSupported) return;
    const track = streamRef.current.getVideoTracks()[0];
    if (!track) return;
    track.applyConstraints({ advanced: [{ torch }] }).catch(() => {});
  }, [torch, open, torchSupported]);

  const tapToFocus = React.useCallback(async () => {
    const track = streamRef.current?.getVideoTracks?.()[0];
    if (!track) return;
    const caps = track.getCapabilities?.() || {};
    if (!Array.isArray(caps.focusMode)) return;
    try {
      if (caps.focusMode.includes('single-shot')) {
        await track.applyConstraints({ advanced: [{ focusMode: 'single-shot' }] });
        setTimeout(() => track.applyConstraints({ advanced: [{ focusMode: 'continuous' }] }).catch(() => {}), 800);
      } else if (caps.focusMode.includes('manual')) {
        await track.applyConstraints({ advanced: [{ focusMode: 'manual' }] });
      }
      dlog('tap-to-focus triggered');
    } catch (e) { dlog('focus trigger failed:', e); }
  }, [dlog]);

  if (!open) return null;
  return (
    <div className="fixed inset-0 z-40 flex flex-col bg-black">
      {/* Live camera feed — tap anywhere on the video to refocus */}
      <video ref={videoRef} autoPlay playsInline muted
        onClick={tapToFocus}
        className="absolute inset-0 w-full h-full object-cover"
        style={{ background: '#000' }}/>
      {/* Vignette overlay */}
      <div className="absolute inset-0 pointer-events-none" style={{
        background: 'radial-gradient(ellipse at center, transparent 35%, rgba(0,0,0,0.55) 100%)'
      }}/>

      <div className="relative z-10 flex items-center justify-between p-4 pt-[max(1rem,env(safe-area-inset-top))]">
        <button onClick={onClose}
          className="w-10 h-10 rounded-full bg-black/50 backdrop-blur text-white grid place-items-center active:scale-95">
          <IconX size={20}/>
        </button>
        <div className="text-white/90 text-[14px] font-medium drop-shadow">Scan serial number</div>
        {torchSupported ? (
          <button onClick={() => setTorch(t => !t)}
            className={`w-10 h-10 rounded-full grid place-items-center active:scale-95
              ${torch ? 'bg-yellow-300 text-neutral-900' : 'bg-black/50 backdrop-blur text-white'}`}>
            {torch ? <IconZap size={18}/> : <IconFlashOff size={18}/>}
          </button>
        ) : <div className="w-10 h-10"/>}
      </div>

      <div className="relative z-10 flex-1 grid place-items-center">
        <div ref={guideRef} className="relative" style={{ width: '78%', aspectRatio: '3/1' }}>
          {[['top-0 left-0', 'border-t-[3px] border-l-[3px] rounded-tl-xl'],
            ['top-0 right-0', 'border-t-[3px] border-r-[3px] rounded-tr-xl'],
            ['bottom-0 left-0', 'border-b-[3px] border-l-[3px] rounded-bl-xl'],
            ['bottom-0 right-0', 'border-b-[3px] border-r-[3px] rounded-br-xl']].map(([pos, cls], i) => (
              <div key={i} className={`absolute ${pos} w-8 h-8 ${cls} transition-colors`}
                style={{ borderColor: flashGreen ? '#22c55e' : 'var(--s3-orange)',
                  animation: 'pulseCorner 1.8s ease-in-out infinite' }}/>
          ))}
          {status === 'scanning' && (
            <div className="absolute left-2 right-2 h-[2px] rounded"
              style={{
                top: `${scanline * 100}%`,
                background: `linear-gradient(90deg, transparent, ${flashGreen ? '#22c55e' : '#FF6A1A'}, transparent)`,
                boxShadow: `0 0 12px ${flashGreen ? '#22c55e' : '#FF6A1A'}`,
                opacity: 0.85,
              }}/>
          )}
        </div>
        <div className="absolute top-[calc(50%+80px)] text-center px-8 max-w-sm">
          {status === 'error' ? (
            <div className="text-white text-[14px] leading-snug bg-red-500/20 border border-red-400/30 rounded-lg px-4 py-3 backdrop-blur">
              {error}
            </div>
          ) : status === 'starting' ? (
            <div className="text-white/80 text-[14px]">Starting camera…</div>
          ) : (
            <>
              <div className="text-white text-[15px] mb-1 drop-shadow">Point at the barcode on your printer's SN sticker.</div>
              <div className="text-white/70 text-[13px] drop-shadow">Hold steady · tap screen to focus · try the torch in dim light.</div>
            </>
          )}
        </div>
      </div>

      <div className="relative z-10 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] flex flex-col items-stretch gap-2">
        {confirmingText ? (
          <div className="self-stretch rounded-2xl bg-white shadow-2xl p-4 flex flex-col gap-3"
            style={{ animation: 'slideUp 220ms cubic-bezier(0.22, 1, 0.36, 1)' }}>
            <div className="flex items-center gap-2">
              <div className="w-6 h-6 rounded-full bg-green-500 text-white grid place-items-center text-[13px] font-bold">✓</div>
              <div className="text-[13px] text-neutral-600">Scanned — confirm before using</div>
            </div>
            <div className="px-3 py-2 rounded-lg bg-neutral-100 font-mono text-[15px] text-neutral-900 break-all">
              {confirmingText}
            </div>
            <div className="flex items-center gap-2">
              <button onClick={rescan}
                className="flex-1 h-11 rounded-full bg-neutral-100 text-neutral-800 text-[14px] font-medium active:scale-95">
                Scan again
              </button>
              <button onClick={confirmScan}
                className="flex-[2] h-11 rounded-full bg-[var(--s3-orange)] text-white text-[14px] font-semibold active:scale-95">
                Use this
              </button>
            </div>
          </div>
        ) : (
          <div className="flex items-center justify-center">
            <button onClick={onClose}
              className="px-4 h-11 rounded-full bg-white/95 text-neutral-900 text-[14px] font-medium active:scale-95">
              Manual Type In
            </button>
          </div>
        )}
      </div>

      <style>{`
        @keyframes pulseCorner {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.6; transform: scale(0.95); }
        }
      `}</style>
    </div>
  );
}

function BottomSheet({ open, onClose, title, children }) {
  if (!open) return null;
  return (
    <div className="fixed inset-0 z-30" onClick={onClose}>
      <div className="absolute inset-0 bg-black/50" style={{animation:'fadeIn 180ms ease'}}/>
      <div
        onClick={e => e.stopPropagation()}
        className="absolute left-0 right-0 bottom-0 bg-white rounded-t-2xl max-h-[85%] flex flex-col shadow-2xl"
        style={{ animation: 'slideUp 260ms cubic-bezier(0.22, 1, 0.36, 1)' }}
      >
        <div className="flex items-center justify-center pt-2 pb-1">
          <div className="w-10 h-1 rounded-full bg-neutral-300"/>
        </div>
        <div className="flex items-center justify-between px-5 py-3 border-b border-neutral-100">
          <div className="font-semibold text-[16px] tracking-tight">{title}</div>
          <button onClick={onClose} className="w-8 h-8 rounded-full hover:bg-neutral-100 grid place-items-center">
            <IconX size={18}/>
          </button>
        </div>
        <div className="overflow-auto px-5 py-4 text-[14px] text-neutral-700 leading-relaxed">
          {children}
        </div>
      </div>
    </div>
  );
}

function FilamentIllustration({ size }) {
  const s = size || 52;
  return (
    <svg width={s} height={s} viewBox="0 0 52 52">
      <defs>
        <radialGradient id="spool" cx="0.3" cy="0.3">
          <stop offset="0" stopColor="#FF8A3D"/>
          <stop offset="1" stopColor="#E55500"/>
        </radialGradient>
      </defs>
      <circle cx="26" cy="26" r="20" fill="url(#spool)"/>
      <circle cx="26" cy="26" r="8" fill="#fff"/>
      <circle cx="26" cy="26" r="3" fill="#FF6A1A"/>
      <g stroke="#fff" strokeWidth="0.6" opacity="0.35" fill="none">
        <circle cx="26" cy="26" r="11"/>
        <circle cx="26" cy="26" r="14"/>
        <circle cx="26" cy="26" r="17"/>
      </g>
    </svg>
  );
}

Object.assign(window, {
  Card, CardHeader, Button, Label, Input, Select,
  PhotoTile, ScannerModal, BottomSheet, ContactLinks, FilamentIllustration,
});
