← back to Dw Photo Capture

server.js

3660 lines

#!/usr/bin/env node
/**
 * DW Pattern Photo Capture — mobile-first (iPhone + iPad) camera worklist.
 * Walk physical sample books, snap each pattern that has no product photo,
 * and the image is saved locally + attached to its Shopify DRAFT product
 * (filename = DW SKU, never the mfr# — honors the private-label leak rule).
 *
 * Pure Node http (zero npm deps). Basic-auth gated. Binds 0.0.0.0 for LAN.
 *   PORT=9890 node server.js
 */
const http = require('http');
const https = require('https');
const zlib = require('zlib');
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');

// ── Process-level safety net (TK-11962) ──────────────────────────────────────
// A single malformed request (verified: POST {dataUrl:{}} → non-string .replace)
// used to throw inside an async req.on('end') handler → unhandled rejection →
// Node v15+ EXITS the whole capture service for ALL users (reproduced 1-request
// DoS). These handlers log and KEEP SERVING: every request here is independent
// (no shared in-flight transaction to corrupt), so surviving one bad request is
// strictly better than a fleet-wide crash. Paired with per-endpoint string
// validation below (defense in depth — catches the NEXT unvalidated-input bug).
process.on('unhandledRejection', (reason) => {
  try { console.error('[unhandledRejection]', (reason && reason.stack) || reason); } catch (e) {}
});
process.on('uncaughtException', (err) => {
  try { console.error('[uncaughtException]', (err && err.stack) || err); } catch (e) {}
});

// ── Test seam (TK-12228) ─────────────────────────────────────────────────────
// `node server.js --test` (DWP_TEST_DIR required) rebases data/ + photos/ into a scratch dir and
// STUBS every Shopify write (shopifyReq) + the whole createNewItem commit (Shopify/dw_unified/FM),
// and skips the catalog/FileMaker boot fetches. Lets a headless browser exercise the real capture
// → persist → display path end to end with zero external writes. pm2/launchd NEVER pass --test.
const TEST_MODE = process.argv.includes('--test');
const TEST_DIR = TEST_MODE ? String(process.env.DWP_TEST_DIR || '') : '';
if (TEST_MODE && !TEST_DIR) { console.error('--test requires DWP_TEST_DIR (scratch dir for data/ + photos/)'); process.exit(2); }
if (TEST_MODE) console.log('[TEST MODE] data+photos under', TEST_DIR, '— Shopify/dw_unified/FileMaker writes STUBBED');

// FileMaker Cloud client (sample-request lookup + sticker-print flag). Loaded guarded so a missing
// dep / unset creds degrade gracefully instead of crashing the scanner.
let FM = null;
try { FM = require('./fm-client.js'); } catch (e) { console.log('FileMaker client unavailable:', e.message); }
const FM_DB = process.env.FM_DB || 'WALLPAPER';
const FM_LAYOUT = process.env.FM_LAYOUT || 'Sample Requests via email';
// WALLPAPER master CREATE layout. MUST expose Name/Color of Pattern (the "Add wallcovering" layout
// does NOT — writing through it silently drops those fields). Confirmed live 2026-09-18 via
// fm.fieldMetadata: this view exposes Series, JS Pattern, Mfr Pattern, Name/Color of Pattern, Width,
// Content, Repeat, Sold Per, Retail Price, vid, Record Type, Internal Description.
const FM_WP_CREATE_LAYOUT = process.env.FM_WP_CREATE_LAYOUT || '*List Wallpapers - Full View';
const FM_PRINT_FLAG = process.env.FM_PRINT_FLAG_FIELD || '';   // Steve designates a flag field the Mac poller reads
const FM_ENABLED = () => !!(FM && process.env.FM_CLARIS_EMAIL && process.env.FM_CLARIS_PASSWORD && process.env.FM_CLOUD_HOST);
// Today's date as MM/DD/YYYY in the business timezone (Kamatera runs UTC; stamp must be Pacific
// so an evening scan never records tomorrow's date on the FileMaker record). Override via FM_TZ.
function todayMDY() { return new Date().toLocaleDateString('en-US', { timeZone: process.env.FM_TZ || 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit' }); }
const VSEARCH_URL = process.env.VSEARCH_URL || 'http://127.0.0.1:9914';   // CLIP visual-search service

// Incoming-samples cache: the FileMaker find on this layout takes ~30s (whole-file scan, can't index
// via API), so refresh the queue in the BACKGROUND and serve it instantly. ≤8-min staleness is fine
// for a "samples coming in" queue. Refreshed at boot + every 8 min + on a stale read.
let _incomingCache = { at: 0, samples: [] }, _incomingLoading = false;
async function refreshIncoming() {
  if (_incomingLoading || !FM_ENABLED() || TEST_MODE) return;
  _incomingLoading = true;
  try {
    const floor = new Date(Date.now() - 120 * 864e5).toLocaleDateString('en-US', { timeZone: process.env.FM_TZ || 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit' });
    const r = await FM.fmFind(FM_DB, FM_LAYOUT, [{ 'Date Sample Request printed for vendor': '>=' + floor, 'Date WP Sample Sent': '=' }], { limit: 40, portal: [] });
    const recs = (r.records || []);
    // Pass 1 — spec-first from the in-RAM active CATALOG (instant, $0). Serve this immediately.
    const samples = recs.map(rec => { const f = rec.fieldData;
      const nk = nmfr(f['Mfr Pattern'] || f['combo sku'] || '');
      const hit = nk ? CATALOG.find(x => x.mfr && nmfr(x.mfr) === nk) : null;
      return { recordId: rec.recordId, mfr_pattern: f['Mfr Pattern'] || null, combo_sku: f['combo sku'] || null,
        series: f['Series'] || null, vid: f['vid'] || null, client: f['company for client fileS'] || null,
        vendor_ordered: f['Date Sample Request printed for vendor'] || null, client_ordered: f['today for client'] || null,
        image: (hit && hit.image) || null, pattern: (hit && hit.title) || null,
        dw_sku: (hit && hit.dw_sku) || null, mfr_sku: (hit && hit.mfr) || null }; });
    _incomingCache = { at: Date.now(), samples };
    // Pass 2 — best-effort images for the CATALOG misses via identifyUnified (full 267k crossref +
    // 173k shopify_products, much wider net). Runs in the BACKGROUND, mutating the served cache in
    // place (serve-stale-while-enriching); concurrency-bounded so 40 samples don't fork 80 psql shells.
    const misses = samples.filter(s => !s.image);
    let idx = 0;
    const worker = async () => { while (idx < misses.length) { const s = misses[idx++];
      let id = s.mfr_pattern ? await identifyUnified(s.mfr_pattern).catch(() => null) : null;
      if ((!id || !id.image) && s.combo_sku) id = await identifyUnified(s.combo_sku).catch(() => null) || id;
      if (id) { if (id.image && !s.image) s.image = id.image;
        if (!s.pattern) s.pattern = id.pattern || id.title || s.pattern;
        if (!s.mfr_sku) s.mfr_sku = id.mfr_sku || s.mfr_sku;
        if (!s.dw_sku) s.dw_sku = id.internal_sku_dash || id.internal_sku || s.dw_sku; } } };
    await Promise.all(Array.from({ length: Math.min(6, misses.length) }, worker));
  } catch (e) { /* keep last good cache */ }
  _incomingLoading = false;
}
setTimeout(refreshIncoming, 9000);                 // warm the cache shortly after boot (once CATALOG is loaded)
setInterval(refreshIncoming, 8 * 60 * 1000);       // keep it fresh

const ROOT = __dirname;
const DATA = TEST_MODE ? path.join(TEST_DIR, 'data') : path.join(ROOT, 'data');
const PHOTOS = TEST_MODE ? path.join(TEST_DIR, 'photos') : path.join(ROOT, 'photos');
const QUEUE_FILE = path.join(DATA, 'queue.json');
const PROGRESS_FILE = path.join(DATA, 'progress.json');
const PORT = process.env.PORT || 9890;
const AUTH_USER = process.env.AUTH_USER || 'admin';
const AUTH_PASS = process.env.AUTH_PASS || 'DW2024!';

// Shopify (for pushing the photo onto the draft product). Token from secrets .env.
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
let TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
// Load selected secrets from the canonical secrets-manager/.env into process.env.
// pm2 launches this process WITHOUT these exported, so without this loop GEMINI_API_KEY
// is empty at runtime and the gemini OCR engine silently falls back to the retired
// Ollama path (resolveEngines → engineAvailable('gemini')===false). Loading it here is
// durable across restarts. Never overwrites an already-set env value.
try {
  const NEED = ['SHOPIFY_ADMIN_TOKEN', 'GEMINI_API_KEY'];
  for (const l of fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8').split('\n')) {
    const i = l.indexOf('='); if (i < 0) continue;
    const k = l.slice(0, i).trim();
    if (NEED.includes(k) && !process.env[k]) process.env[k] = l.slice(i + 1).trim();
  }
} catch (e) {}
if (!TOKEN) TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
if (TEST_MODE) TOKEN = 'TEST-STUB';   // never a real token in test mode — shopifyReq below is stubbed

fs.mkdirSync(PHOTOS, { recursive: true });

// ── EVERY scan is saved (Steve: "EVERY scan must be saved to make app better") ──────────────────
// Persist the raw photo(s) + what the OCR read + engines + resolved identity to an append-only log,
// so mis-reads are reviewable (raw image ⇄ OCR ⇄ resolved SKU) and the corpus can drive engine
// tuning / vendor-spec learning. Best-effort + fully async — a save failure NEVER breaks a scan.
const SCAN_LOG = path.join(DATA, 'scan-log.jsonl');
try { fs.mkdirSync(DATA, { recursive: true }); } catch (e) {}
let _scanN = 0;
function saveScan(imgs, meta) {
  try {
    const stamp = new Date().toISOString();
    const id = stamp.replace(/[:.]/g, '-') + '-' + ((++_scanN) % 100000) + Math.random().toString(36).slice(2, 5);
    const strip = s => (s || '').replace(/^data:image\/\w+;base64,/, '');
    const images = {};
    for (const [k, v] of Object.entries(imgs || {})) {
      if (!v) continue;
      let b; try { b = Buffer.from(strip(v), 'base64'); } catch (e) { continue; }
      if (b.length < 200 || b.length > 12 * 1024 * 1024) continue;    // skip empty / oversized
      const fn = `scan-${id}-${k}.jpg`;
      fs.writeFile(path.join(PHOTOS, fn), b, () => {});                 // async, fire-and-forget
      images[k] = `/photos/${fn}`;
    }
    const rec = Object.assign({ id, at: stamp, images }, meta || {});
    fs.appendFile(SCAN_LOG, JSON.stringify(rec) + '\n', () => {});
  } catch (e) { /* never let logging break a scan */ }
}

// ── Per-vendor photo archive: every uploaded photo is ALSO filed under
//    photos/vendors/<Vendor>/ and indexed in data/vendor-photos.jsonl, so all photos are
//    kept and browsable by vendor (/vendor-photos). Hard-links the already-written file (no
//    extra disk), falls back to a copy. Never throws — archiving must not break an upload.
const VENDOR_DIR = path.join(PHOTOS, 'vendors');
const VENDOR_LOG = path.join(DATA, 'vendor-photos.jsonl');
const safeSeg = (s, n) => String(s || '').replace(/[^A-Za-z0-9 ._&-]/g, '').replace(/\s+/g, ' ').trim().slice(0, n);
function vendorForSku(dw_sku) {
  const s = String(dw_sku || '').toUpperCase().replace(/-SAMPLE$/, '');   // sample shots share the base SKU's vendor
  if (!s) return '';
  const hit = CATALOG.find(x => (x.dw_sku || '').toUpperCase() === s);
  if (hit && hit.vendor) return hit.vendor;
  if (SHEET_BY_GRS[s]) return 'Fentucci';                       // TWIL GRS sheet items are created as Fentucci
  const pr = progress[dw_sku] || progress[s];
  return (pr && (pr.vendor || (pr.meta && pr.meta.vendor))) || '';
}
function archiveVendorPhoto(srcFile, { vendor, dw_sku, source, at, name } = {}) {
  try {
    const v = safeSeg(vendor || vendorForSku(dw_sku), 80) || '_unassigned';
    const dir = path.join(VENDOR_DIR, v);
    if (!dir.startsWith(VENDOR_DIR + path.sep)) return null;
    fs.mkdirSync(dir, { recursive: true });
    const base = safeSeg(name, 200).replace(/ /g, '_') || path.basename(srcFile);
    const dest = path.join(dir, base);
    if (!fs.existsSync(dest)) { try { fs.linkSync(srcFile, dest); } catch (e) { fs.copyFileSync(srcFile, dest); } }
    const rec = { at: at || new Date().toISOString(), vendor: v, dw_sku: dw_sku || null, source: source || null,
      path: '/photos/vendors/' + encodeURIComponent(v) + '/' + encodeURIComponent(base) };
    fs.appendFileSync(VENDOR_LOG, JSON.stringify(rec) + '\n');
    return rec;
  } catch (e) { console.error('[vendor-photos] archive failed', srcFile, e.message); return null; }
}
// (archiveVendorB64 retired TK-12228: create-item photos now go through savePhotoB64 → archiveVendorPhoto
//  so each file carries its front/back side and is indexed in data/captures.jsonl.)
// ── Capture history (TK-12228 — Steve: "every photo, front and back, must show display on the app") ──
// Every capture that reaches the server is persisted under photos/ with its SIDE in the filename and
// indexed in data/captures.jsonl as ONE record per item (front + back + extras together), so the
// result view and the /captures history grid can show BOTH sides. Batch-mode shots are read from
// their existing per-session manifests; captures from before this log existed are reconstructed from
// the vendor-photo archive log (sides inferred from the -0/-1 filename tag).
const CAPTURE_LOG = path.join(DATA, 'captures.jsonl');
const SIDES = ['front', 'back', 'extra', 'photo'];
// sides[] for an ordered upload list [front?, back?, ...extras] given which of front/back are present.
function sidesFor(n, frontPresent, backPresent) {
  const out = []; let i = 0;
  if (frontPresent !== false && i < n) out[i++] = 'front';
  if (backPresent && i < n) out[i++] = 'back';
  while (i < n) out[i++] = 'extra';
  return out;
}
const cleanSide = s => (SIDES.includes(s) ? s : 'photo');
// Write one base64 JPEG under photos/ as <SKU>-<side>-<ts>-<i>.jpg → its /photos/ URL (null on failure).
function savePhotoB64(b64, dw_sku, side, i) {
  try {
    const buf = Buffer.from(String(b64 || ''), 'base64');
    if (buf.length < 200) return null;
    const safe = String(dw_sku || 'nosku').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 80) || 'nosku';
    const fname = `${safe}-${cleanSide(side)}-${Date.now()}-${i || 0}.jpg`;
    fs.writeFileSync(path.join(PHOTOS, fname), buf);
    return { file: path.join(PHOTOS, fname), url: `/photos/${fname}` };
  } catch (e) { console.error('[captures] save failed', e.message); return null; }
}
function recordCapture(rec) {
  try {
    const at = new Date().toISOString();
    const full = Object.assign({ id: at.replace(/[:.]/g, '-') + '-' + Math.random().toString(36).slice(2, 7), at }, rec);
    full.photos = (full.photos || []).filter(p => p && p.url).map(p => ({ side: cleanSide(p.side), url: p.url }));
    fs.appendFileSync(CAPTURE_LOG, JSON.stringify(full) + '\n');
    return full;
  } catch (e) { console.error('[captures] log failed', e.message); return null; }
}
const readJsonl = f => { const out = []; try { for (const l of fs.readFileSync(f, 'utf8').split('\n')) { if (!l) continue; try { out.push(JSON.parse(l)); } catch (e) {} } } catch (e) {} return out; };
// Merge the three sources into one newest-first list of { id, at, source, dw_sku, vendor, photos[] }.
function listCaptures() {
  const items = [], seen = new Set();
  const base = u => { try { return decodeURIComponent(String(u).split('/').pop()); } catch (e) { return String(u).split('/').pop(); } };
  for (const r of readJsonl(CAPTURE_LOG)) {
    if (!r || !Array.isArray(r.photos)) continue;
    r.photos.forEach(p => seen.add(base(p.url)));
    items.push(Object.assign({ source: 'capture' }, r));
  }
  // batch manifests: one record per (session, sku, seq); psku/front → front, info/back → back
  const mdir = path.join(DATA, 'batch-sessions');
  try {
    for (const m of fs.readdirSync(mdir)) {
      if (!/\.jsonl$/.test(m)) continue;
      const groups = new Map();
      for (const r of readJsonl(path.join(mdir, m))) {
        const key = `${r.sessionId}|${r.sku}|${r.seq == null ? '' : r.seq}`;
        let g = groups.get(key);
        if (!g) { g = { id: 'batch:' + key, at: r.at, source: 'batch', session: r.sessionId, dw_sku: r.sku, vendor: r.vendor || null, bySide: {} }; groups.set(key, g); }
        if (r.at && r.at < g.at) g.at = r.at;
        const ms = r.meta && r.meta.side;
        const side = ms === 'info' || ms === 'back' ? 'back' : 'front';   // psku/front/unsided single → front
        const pth = r.paths || {};
        const url = pth.web || pth.master || pth.original;
        if (url) g.bySide[side] = url;
      }
      for (const g of groups.values()) {
        g.photos = ['front', 'back'].filter(s => g.bySide[s]).map(s => ({ side: s, url: g.bySide[s] }));
        delete g.bySide; if (g.photos.length) items.push(g);
      }
    }
  } catch (e) { /* no batch sessions yet */ }
  // legacy uploads (before captures.jsonl): the vendor-photo archive log, grouped per SKU+source+~2min
  const legacy = [];
  const vseen = new Set();
  for (const r of readJsonl(VENDOR_LOG)) {
    if (!r || !r.path || vseen.has(r.path)) continue; vseen.add(r.path);
    if (/batch/.test(r.source || '')) continue;                   // batch shots come from their manifests
    const b = base(r.path);
    if (seen.has(b) || /^(scan-|_ocr-)/.test(b)) continue;
    legacy.push(r);
  }
  legacy.sort((a, b) => String(a.at).localeCompare(String(b.at)));
  let cur = null;
  for (const r of legacy) {
    const t = Date.parse(r.at) || 0;
    if (!cur || cur.dw_sku !== (r.dw_sku || null) || cur._src !== r.source || t - cur._t > 120000) {
      cur = { id: 'legacy:' + r.path, at: r.at, source: 'legacy-' + (r.source || 'upload'), _src: r.source, _t: t, dw_sku: r.dw_sku || null, vendor: r.vendor || null, photos: [] };
      items.push(cur);
    }
    const tag = /-\d{10,}-(\d+)\.jpe?g$/i.exec(base(r.path));
    const side = tag ? (tag[1] === '0' ? 'front' : tag[1] === '1' ? 'back' : 'extra') : 'photo';
    cur.photos.push({ side, url: r.path, inferred: true });
  }
  for (const it of items) { delete it._src; delete it._t; }
  items.sort((a, b) => String(b.at).localeCompare(String(a.at)));
  return items;
}
// One-time backfill of photos saved before the archive existed (flat photos/<SKU>-*.jpg and
// batch/<session>/). Runs once the catalog is loaded; marker file makes it idempotent.
const VENDOR_BACKFILL_MARK = path.join(DATA, 'vendor-photos.backfilled');
function backfillVendorPhotos() {
  if (fs.existsSync(VENDOR_BACKFILL_MARK) || !CATALOG.length) return;
  let n = 0;
  const done = new Set();                                        // files already archived live — don't re-file them
  try { for (const l of fs.readFileSync(VENDOR_LOG, 'utf8').split('\n')) { try { done.add(decodeURIComponent(JSON.parse(l).path.split('/').pop())); } catch (e) {} } } catch (e) {}
  try {
    for (const f of fs.readdirSync(PHOTOS)) {
      if (!/\.jpe?g$/i.test(f) || /^(scan-|_ocr-)/.test(f)) continue;
      const full = path.join(PHOTOS, f); if (!fs.statSync(full).isFile()) continue;
      const sku = f.replace(/-\d{10,}(-\d+)?\.jpe?g$/i, '');
      if (done.has(f)) continue;
      if (archiveVendorPhoto(full, { dw_sku: sku, source: 'backfill', at: fs.statSync(full).mtime.toISOString() })) n++;
    }
    const mdir = path.join(DATA, 'batch-sessions');
    if (fs.existsSync(mdir)) for (const m of fs.readdirSync(mdir)) {
      for (const line of fs.readFileSync(path.join(mdir, m), 'utf8').split('\n')) {
        let r; try { r = JSON.parse(line); } catch (e) { continue; }
        for (const p of Object.values(r.paths || {})) {
          const full = path.join(PHOTOS, decodeURIComponent(p.replace(/^\/photos\//, '')));
          if (done.has(r.sessionId + '_' + path.basename(full))) continue;
          if (full.startsWith(PHOTOS + path.sep) && fs.existsSync(full) &&
              archiveVendorPhoto(full, { vendor: r.vendor, dw_sku: r.sku, source: 'backfill-batch', at: r.at, name: r.sessionId + '_' + path.basename(full) })) n++;
        }
      }
    }
    fs.writeFileSync(VENDOR_BACKFILL_MARK, new Date().toISOString() + ' ' + n + '\n');
    console.log('[vendor-photos] backfilled', n);
  } catch (e) { console.error('[vendor-photos] backfill failed', e.message); }
}
const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { return d; } };
const saveJSON = (f, o) => {
  // ATOMIC write: serialize to a temp file then rename over the target. rename() is atomic on
  // POSIX, so a crash or ENOSPC mid-write can never truncate/corrupt the real file (the learned
  // vendor_profiles.json etc.). On failure, remove the partial temp and rethrow (same API).
  const tmp = `${f}.tmp-${process.pid}`;
  try { fs.writeFileSync(tmp, JSON.stringify(o, null, 2)); fs.renameSync(tmp, f); }
  catch (e) { try { fs.unlinkSync(tmp); } catch (e2) {} throw e; }
};

// Auto-version: derive a sequential build number from the actual file contents.
// Any change to index.html or server.js → new hash → next build number. Never manual.
const crypto = require('crypto');
function buildLabel() {
  let blob = '';
  try { blob += fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8'); } catch (e) {}
  try { blob += fs.readFileSync(path.join(ROOT, 'server.js'), 'utf8'); } catch (e) {}
  const hash = crypto.createHash('md5').update(blob).digest('hex').slice(0, 8);
  const reg = loadJSON(path.join(DATA, 'build.json'), { next: 2, map: {} });
  if (!reg.map[hash]) { reg.map[hash] = reg.next++; saveJSON(path.join(DATA, 'build.json'), reg); }
  return `v${reg.map[hash]} · editor`;
}

// progress.json: { "<dw_sku>": { done, skipped, photo, ts, shopify_pushed, push_err } }
let progress = loadJSON(PROGRESS_FILE, {});
const saveProgress = () => saveJSON(PROGRESS_FILE, progress);

// recents.json / favorites.json: card-shaped entries keyed by dw_sku so the
// "🕘 Recent" and "⭐ Favorites" views render full cards without a Shopify lookup.
// Server-side (not localStorage) so the same list shows on phone AND office.
const RECENTS_FILE = path.join(DATA, 'recents.json');
const FAVORITES_FILE = path.join(DATA, 'favorites.json');
let recents = loadJSON(RECENTS_FILE, {});
let favorites = loadJSON(FAVORITES_FILE, {});
const saveRecents = () => saveJSON(RECENTS_FILE, recents);
const saveFavorites = () => saveJSON(FAVORITES_FILE, favorites);
// Build a card-shaped record from the display meta the client sends.
function cardRecord(dw_sku, meta, extra) {
  meta = meta || {};
  return Object.assign({
    dw_sku, product_id: meta.product_id || null, title: meta.title || dw_sku,
    mfr: meta.mfr || '', price: meta.price || null, image: meta.image || null,
    status: meta.status || '', keep_images: true
  }, extra || {});
}

function shopifyReq(method, path, payload) {
  return new Promise((resolve) => {
    if (TEST_MODE) {   // TK-12228 test seam: no network. Image POSTs "succeed" so attach paths run; all else is inert.
      if (method === 'POST' && /\/images\.json$/.test(path)) return resolve({ status: 201, body: { image: { id: Date.now() } }, raw: '' });
      return resolve({ status: 0, body: null, err: 'test-stub' });
    }
    if (!TOKEN) return resolve({ status: 0, body: null, err: 'no token' });
    const data = payload ? JSON.stringify(payload) : null;
    const req = https.request({
      host: SHOP, path: `/admin/api/${API}${path}`, method,
      headers: Object.assign({ 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
        data ? { 'Content-Length': Buffer.byteLength(data) } : {})
    }, (res) => {
      let d = ''; res.on('data', c => d += c);
      res.on('end', () => { let b = null; try { b = JSON.parse(d); } catch (e) {} resolve({ status: res.statusCode, body: b, raw: d }); });
    });
    req.on('error', e => resolve({ status: 0, err: e.message }));
    if (data) req.write(data); req.end();
  });
}
function gql(query, variables) {
  return shopifyReq('POST', '/graphql.json', { query, variables: variables || {} }).then(r => (r.body || {}));
}

// keepOthers=false → clean re-shoot (delete siblings, the Fentucci placeholder workflow).
// keepOthers=true  → add the phone photo as the FEATURED image but PRESERVE existing
//   imagery — the safe default for arbitrary live SKUs that may already have good
//   room/lifestyle shots we must not destroy (customer-facing, hard to reverse).
async function shopifyAttachImage(productId, dwSku, base64, keepOthers = false) {
  const safe = (dwSku || ('SKU' + productId)).replace(/[^A-Za-z0-9._-]/g, '');
  // new photo becomes the FEATURED image (position 1) — so it's the one shown first
  const r = await shopifyReq('POST', `/products/${productId}/images.json`,
    { image: { attachment: base64, filename: `${safe}.jpg`, position: 1 } });
  if (!(r.status >= 200 && r.status < 300)) return { ok: false, err: `HTTP ${r.status}: ${(r.raw || '').slice(0, 160)}` };
  const newId = r.body && r.body.image && r.body.image.id;
  if (!keepOthers) {
    // remove older images so the fresh photo is the only/primary one (clean re-shoot)
    const all = await shopifyReq('GET', `/products/${productId}/images.json`);
    const imgs = (all.body && all.body.images) || [];
    for (const im of imgs) { if (im.id !== newId) await shopifyReq('DELETE', `/products/${productId}/images/${im.id}.json`); }
  }
  return { ok: true, image_id: newId, kept_others: keepOthers };
}

// Append an ADDITIONAL image — no sibling delete, no position:1, so the existing
// featured image is untouched. Used by the multi-photo batch add.
async function shopifyAppendImage(productId, dwSku, base64, idx) {
  const safe = (dwSku || ('SKU' + productId)).replace(/[^A-Za-z0-9._-]/g, '');
  const r = await shopifyReq('POST', `/products/${productId}/images.json`,
    { image: { attachment: base64, filename: `${safe}-${idx}.jpg` } });
  if (!(r.status >= 200 && r.status < 300)) return { ok: false, err: `HTTP ${r.status}: ${(r.raw || '').slice(0, 120)}` };
  return { ok: true, image_id: r.body && r.body.image && r.body.image.id };
}

// ── Video upload to a Shopify product (staged upload → productCreateMedia) ──
function gqlFull(query, variables) { return shopifyReq('POST', '/graphql.json', { query, variables: variables || {} }); }
async function shopifyAddVideo(productId, buf, filename, mime) {
  // 1) reserve a staged upload slot
  const su = await gqlFull(`mutation($in:[StagedUploadInput!]!){ stagedUploadsCreate(input:$in){
      stagedTargets{ url resourceUrl parameters{name value} } userErrors{message} } }`,
    { in: [{ resource: 'VIDEO', filename, mimeType: mime, httpMethod: 'POST', fileSize: String(buf.length) }] });
  const tgt = su.body && su.body.data && su.body.data.stagedUploadsCreate && su.body.data.stagedUploadsCreate.stagedTargets[0];
  if (!tgt) return { ok: false, err: 'stagedUploadsCreate failed: ' + JSON.stringify((su.body || {}).errors || (su.body && su.body.data && su.body.data.stagedUploadsCreate && su.body.data.stagedUploadsCreate.userErrors) || '').slice(0, 200) };
  // 2) multipart POST the bytes to the staged target (GCS)
  const boundary = '----dwvid' + Buffer.from(filename).toString('hex').slice(0, 12) + buf.length;
  const pre = [];
  for (const p of tgt.parameters) pre.push(`--${boundary}\r\nContent-Disposition: form-data; name="${p.name}"\r\n\r\n${p.value}\r\n`);
  pre.push(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`);
  const body = Buffer.concat([Buffer.from(pre.join(''), 'utf8'), buf, Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8')]);
  const up = await new Promise(resolve => {
    const U = new URL(tgt.url);
    const r = https.request({ host: U.host, path: U.pathname + U.search, method: 'POST',
      headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length } },
      res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve({ status: res.statusCode, body: d })); });
    r.on('error', e => resolve({ status: 0, err: e.message })); r.write(body); r.end();
  });
  if (up.status < 200 || up.status >= 300) return { ok: false, err: `staged upload HTTP ${up.status}: ${(up.body || up.err || '').slice(0, 160)}` };
  // 3) attach the staged video to the product
  const cm = await gqlFull(`mutation($id:ID!,$media:[CreateMediaInput!]!){ productCreateMedia(productId:$id, media:$media){
      media{ status } mediaUserErrors{ message } } }`,
    { id: `gid://shopify/Product/${productId}`, media: [{ originalSource: tgt.resourceUrl, mediaContentType: 'VIDEO' }] });
  const errs = cm.body && cm.body.data && cm.body.data.productCreateMedia && cm.body.data.productCreateMedia.mediaUserErrors;
  if (errs && errs.length) return { ok: false, err: errs.map(e => e.message).join('; ') };
  return { ok: true, processing: true }; // Shopify transcodes async
}

// Poll a product's VIDEO media processing state (Shopify transcodes async: UPLOADED→PROCESSING→READY|FAILED).
async function shopifyMediaStatus(productId) {
  const q = `query($id:ID!){ product(id:$id){ media(first:50){ edges{ node{ mediaContentType status } } } } }`;
  const d = await gql(q, { id: `gid://shopify/Product/${productId}` });
  const nodes = ((d.data && d.data.product && d.data.product.media && d.data.product.media.edges) || []).map(e => e.node);
  const vids = nodes.filter(n => n.mediaContentType === 'VIDEO');
  const ready = vids.filter(v => v.status === 'READY').length;
  const failed = vids.filter(v => v.status === 'FAILED').length;
  const processing = vids.length - ready - failed;
  return { total: vids.length, ready, processing, failed, all_ready: vids.length > 0 && processing === 0 && failed === 0 };
}

// After a photo lands, make the SKU live IF it passes the activation gate
// (now has image + a real price + width + a description). Else stays draft.
async function shopifyActivateIfReady(productId) {
  const q = `query($id:ID!){ product(id:$id){ status descriptionHtml
      imgs:images(first:1){edges{node{id}}}
      w:metafield(namespace:"global",key:"width"){value} w2:metafield(namespace:"custom",key:"width"){value}
      variants(first:5){edges{node{ title sku price }}} } }`;
  const d = await gql(q, { id: `gid://shopify/Product/${productId}` });
  const p = d && d.data && d.data.product;
  if (!p) return { live: false, reason: 'lookup failed' };
  const hasImg = p.imgs.edges.length > 0;
  const hasWidth = !!((p.w && p.w.value) || (p.w2 && p.w2.value));
  const hasDesc = !!(p.descriptionHtml && p.descriptionHtml.replace(/<[^>]+>/g, '').trim().length > 20);
  const roll = p.variants.edges.map(e => e.node).find(v => !(v.sku || '').endsWith('-Sample') && (v.title || '').toLowerCase() !== 'sample');
  const hasPrice = roll && parseFloat(roll.price) > 4.25;
  if (!(hasImg && hasWidth && hasDesc && hasPrice)) {
    const miss = [!hasImg && 'image', !hasPrice && 'price', !hasWidth && 'width', !hasDesc && 'description'].filter(Boolean);
    return { live: false, reason: 'needs ' + miss.join(' + ') };
  }
  if (p.status === 'ACTIVE') return { live: true, already: true };
  // set active
  const up = await shopifyReq('PUT', `/products/${productId}.json`, { product: { id: productId, status: 'active' } });
  if (up.status < 200 || up.status >= 300) return { live: false, reason: `activate HTTP ${up.status}` };
  // publish to all sales channels
  const pubs = await gql('{ publications(first:50){edges{node{id}}} }');
  const ids = ((pubs.data && pubs.data.publications && pubs.data.publications.edges) || []).map(e => ({ publicationId: e.node.id }));
  await gql(`mutation($id:ID!,$in:[PublicationInput!]!){ publishablePublish(id:$id,input:$in){ userErrors{message} } }`,
    { id: `gid://shopify/Product/${productId}`, in: ids });
  return { live: true, channels: ids.length };
}

function checkAuth(req) {
  const h = req.headers.authorization || '';
  if (!h.startsWith('Basic ')) return false;
  const raw = Buffer.from(h.slice(6), 'base64').toString();
  const ci = raw.indexOf(':'); const u = ci < 0 ? raw : raw.slice(0, ci), p = ci < 0 ? '' : raw.slice(ci + 1);  // RFC: only first ':' splits
  return u === AUTH_USER && p === AUTH_PASS;
}

// ───────────────────────── Remote-shutter pairing layer ─────────────────────────
// Zero-new-dep: SSE (server→client push) + POST (client→server). One implicit room
// since it's one operator — a single desktop "control" page pairs with a single
// phone "cam" page. Either role can reconnect at will; the other side sees a live
// status. No room codes needed (single-user). The desktop clicks SHOOT → we push a
// `shoot` event to the phone → phone grabs a native-res frame, uploads it through the
// EXISTING /api/photo pipeline → phone POSTs a `shot` ping → we push the result to
// the desktop, which pulls the photo and routes it into the normal attach workflow.
const ROOM = {
  cam: null,      // { res, lastSeen } — the phone's SSE response stream
  desk: null,     // { res, lastSeen } — the desktop's SSE response stream
  camLive: false, // phone reports its getUserMedia stream is live
  camInfo: '',    // e.g. "1920x1080 · environment"
  lastShot: null  // { dw_sku, product_id, ok, live, live_reason, push_err, photo, ts }
};
function sseInit(res) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive', 'X-Accel-Buffering': 'no'
  });
  res.write('retry: 3000\n\n'); // tell EventSource to auto-reconnect after 3s
}
function sseSend(slot, event, data) {
  const conn = ROOM[slot];
  if (!conn || !conn.res) return false;
  try { conn.res.write(`event: ${event}\ndata: ${JSON.stringify(data || {})}\n\n`); return true; }
  catch (e) { return false; }
}
function pairStatus() {
  const now = Date.now();
  const fresh = c => c && c.res && (now - c.lastSeen) < 20000;
  return {
    cam_connected: fresh(ROOM.cam), desk_connected: fresh(ROOM.desk),
    cam_live: !!(fresh(ROOM.cam) && ROOM.camLive), cam_info: ROOM.camInfo,
    last_shot: ROOM.lastShot
  };
}
// Push the current pairing status to BOTH ends so each page's banner stays accurate.
function broadcastStatus() {
  const s = pairStatus();
  sseSend('cam', 'status', s);
  sseSend('desk', 'status', s);
}
// Heartbeat: keep SSE sockets warm (proxies/iOS drop idle streams) + expire stale roles.
setInterval(() => {
  for (const slot of ['cam', 'desk']) {
    const c = ROOM[slot];
    if (c && c.res) { try { c.res.write(': ping\n\n'); } catch (e) { ROOM[slot] = null; } }
  }
  broadcastStatus();
}, 8000);

function buildQueue() {
  const q = loadJSON(QUEUE_FILE, []);
  return q.map(item => {
    const pr = progress[item.dw_sku] || {};
    return { ...item, done: !!pr.done, skipped: !!pr.skipped, photo: pr.photo || null, ts: pr.ts || null,
             shopify_pushed: !!pr.shopify_pushed, push_err: pr.push_err || null,
             live: !!pr.live, live_reason: pr.live_reason || null };
  });
}

function send(res, code, body, headers = {}) {
  res.writeHead(code, { 'Content-Type': 'application/json', ...headers });
  res.end(typeof body === 'string' ? body : JSON.stringify(body));
}

// ── Color-index (PDP color-dot → catalog-wide "index of many within 10%") ──────
// ONE named tolerance knob. "10%" is expressed as a fraction of the practical
// perceptual CIELAB ΔE76 range (~100 for two clearly-different colors), so 10%
// maps to a ΔE ceiling of ~10 — tight enough to stay same-family, loose enough
// to pull MANY products (verified: 41–1001 in-tolerance across sampled hues).
// Change COLOR_INDEX_TOLERANCE_PCT alone to retune; the ceiling derives from it.
const COLOR_INDEX_TOLERANCE_PCT = 0.10;
const COLOR_INDEX_DELTA_E_CEILING = COLOR_INDEX_TOLERANCE_PCT * 100; // = 10 (ΔE76)
const COLOR_INDEX_PATH = require('path').join(__dirname, 'data', 'color-index.json');
let _colorIndex = null, _colorIndexMtime = 0;
// Load the static index once; hot-reload if the file is regenerated (mtime change).
function loadColorIndex() {
  const fs = require('fs');
  const st = fs.statSync(COLOR_INDEX_PATH); // throws if missing → handler 503s
  if (_colorIndex && st.mtimeMs === _colorIndexMtime) return _colorIndex;
  const j = JSON.parse(fs.readFileSync(COLOR_INDEX_PATH, 'utf8'));
  _colorIndex = Array.isArray(j.items) ? j.items : [];
  _colorIndexMtime = st.mtimeMs;
  return _colorIndex;
}
// ── Color-DOTS (shop-by-color WHEEL swatch set) ────────────────────────────────
// The wheel's clickable dots must be REAL DB colors only, each backing >=20
// patterns, ordered by most-used qty, and each visually UNIQUE (ΔE-collapsed).
// Built by scripts/build-color-dots.cjs → data/color-dots.json. Served read-only,
// hot-reloaded on mtime like the color index. See dw-shop-by-color-wheel memory.
const COLOR_DOTS_PATH = require('path').join(__dirname, 'data', 'color-dots.json');
let _colorDots = null, _colorDotsMtime = 0;
function loadColorDots() {
  const fs = require('fs');
  const st = fs.statSync(COLOR_DOTS_PATH); // throws if missing → handler 503s
  if (_colorDots && st.mtimeMs === _colorDotsMtime) return _colorDots;
  _colorDots = JSON.parse(fs.readFileSync(COLOR_DOTS_PATH, 'utf8'));
  _colorDotsMtime = st.mtimeMs;
  return _colorDots;
}
// sRGB hex → CIELAB (D65), matching how product_colors LAB was computed upstream.
function hexToLab(hex) {
  hex = hex.replace('#', '');
  let r = parseInt(hex.slice(0, 2), 16) / 255,
      g = parseInt(hex.slice(2, 4), 16) / 255,
      b = parseInt(hex.slice(4, 6), 16) / 255;
  const lin = c => (c > 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92);
  r = lin(r); g = lin(g); b = lin(b);
  let X = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
  let Y = (r * 0.2126 + g * 0.7152 + b * 0.0722);
  let Z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
  const f = t => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
  return { l: 116 * f(Y) - 16, a: 500 * (f(X) - f(Y)), b: 200 * (f(Y) - f(Z)) };
}

// ── OCR → ranked SKU candidates ────────────────────────────────────────────────
// Allocated ONCE at load (the live scanner hits /api/ocr ~every 600ms, so we never
// want to rebuild these regexes per request). analyzeOcr() is pure — it takes parsed
// rows and returns the response shape, so the ranking is unit-testable without HTTP.
//
// Brand lexicon: when one of these vendor names is printed on the swatch, the biggest
// alnum code on the page is almost certainly its model #/SKU — so we boost a code whose
// prefix matches that brand and let the scanner lock on the FIRST read (topStrong).
const VENDOR_LEX = [
  { name: 'Winfield Thybony', re: /WINFIELD\s*THYBONY|THYBONY/, pfx: /^WD/ },
  { name: 'Fentucci',         re: /FENTUCCI/,                   pfx: /^(GRS|WOS|SG|BA)/ },
  { name: 'Phillip Jeffries', re: /PHILLIP\s*JEFFRIES/,         pfx: /^(WC|PJ)/ },
  { name: 'Thibaut',          re: /THIBAUT|ANNA\s*FRENCH/,      pfx: /^(T|AT|AF)\d/ },
  { name: 'Schumacher',       re: /SCHUMACHER/,                 pfx: /^\d{4,}/ },
  { name: 'TWIL',             re: /\bTWIL\b/,                   pfx: /^TWIL/ },
];
const STRONG_PFX = /^(GRS-|WD[A-Z]*\d|DWNAT|DW[A-Z]{2})/;   // self-evidently a SKU, no brand needed
const CODE_TOKEN = /[A-Z0-9][A-Z0-9-]{2,13}/g;             // candidate token shape

// Designer Wallcoverings' OWN header (company name / phone / website) is printed on every DW sample
// sticker — it is NEVER the product identity. The vendor MODEL # is what runs down the SKU, so scrub
// our own branding first: our phone (any punctuation form + fragments), phone-shaped tokens, and the
// DW name/site lines. Configurable via DW_PHONE.
const DW_PHONE = (process.env.DW_PHONE || '8883734564');                 // 888-373-4564
const DW_BRAND_RE = /designer\s*wall\s*cover|designerwallcoverings|wallcoverings?\.com|\bDWC\b/i;
const PHONE_TOKEN_RE = /^(1?[-.\s(]*8(?:00|88|77|66|55)[-.\s)]*\d{3}[-.\s]*\d{4}|\d{10})$/;  // toll-free / 10-digit
function isDwNoise(tok, dwLabel) {
  const s = String(tok); const d = s.replace(/\D/g, '');
  if (!d) return false;
  if (d === DW_PHONE) return true;                              // our exact phone
  if (d.length >= 7 && DW_PHONE.includes(d)) return true;       // a 7+ digit fragment of it (373-4564)
  if (PHONE_TOKEN_RE.test(s)) return true;                      // any 10-digit / toll-free number
  if (dwLabel && /^\d+$/.test(s) && d.length >= 3 && DW_PHONE.includes(d)) return true;  // phone fragment on OUR label
  return false;
}

// ── Field vocabulary: classify a label read into sku# / model# / name / color ──
// Common interior-design colorway names + basic colors, so a printed color name is caught.
const COLOR_WORDS = 'oatmeal|alabaster|greige|celadon|ecru|taupe|linen|flax|dove|pewter|charcoal|ebony|onyx|ivory|cream|bone|chalk|parchment|sand|camel|fawn|mushroom|mocha|espresso|chocolate|walnut|honey|amber|ochre|mustard|saffron|terracotta|rust|clay|brick|coral|blush|rose|blossom|mauve|plum|aubergine|burgundy|claret|scarlet|crimson|cardinal|sage|celery|moss|fern|olive|forest|emerald|jade|hunter|teal|aqua|turquoise|peacock|cerulean|cobalt|navy|indigo|denim|slate|sky|powder|periwinkle|lavender|lilac|violet|amethyst|gold|brass|bronze|copper|champagne|platinum|silver|graphite|smoke|stone|pearl|white|black|grey|gray|beige|blue|green|red|yellow|orange|purple|pink|brown|tan';
const COLOR_RE = new RegExp('\\b(' + COLOR_WORDS + ')\\b', 'i');
const COLOR_LABEL_RE = /colou?r(?:way)?\s*[:#]?\s*([A-Za-z][A-Za-z '/-]{1,28})/i;   // "Color: Oatmeal"
const NAME_LABEL_RE = /(?:pattern|design|collection|name)\s*[:#]?\s*([A-Za-z][A-Za-z0-9 '/-]{2,34})/i;
const CODE_LABEL_RE = /(?:sku|item|model|mfr|product|pattern)\s*(?:#|no\.?|number)?\s*[:#]?\s*([A-Z0-9][A-Z0-9-]{2,15})/i;

// From an OCR read (rows + analyzeOcr result), pull the human-facing fields the UI highlights:
// sku# (primary code), model#/mfr# (labeled or secondary code), name (pattern/collection), color.
function classifyFields(rows, a) {
  const lines = rows.filter(r => !r.bc).map(r => String(r.t).trim()).filter(Boolean);
  const raw = lines.join('\n');
  const sku = a.top || null;
  // color: an explicit "Color: X" wins; else the first known color word anywhere on the label.
  let color = null;
  const cl = COLOR_LABEL_RE.exec(raw); if (cl) color = cl[1].trim();
  if (!color) { const cw = COLOR_RE.exec(raw); if (cw) color = cw[1]; }
  if (color) color = color.replace(/\s+/g, ' ').trim().replace(/[ ,.;]+$/, '');
  // model#/mfr#: a labeled code that differs from the SKU, else a distinct secondary code token.
  let model = null;
  const clab = CODE_LABEL_RE.exec(raw);
  if (clab && clab[1].toUpperCase().replace(/\s+/g, '') !== sku) model = clab[1].toUpperCase().replace(/\s+/g, '');
  if (!model) { const other = (a.candidates || []).find(c => c !== sku); if (other) model = other; }
  // name: a labeled Pattern/Collection wins; else the longest alpha line that isn't the vendor,
  // the color, or a code-bearing line (marketing prose is otherwise ignored by the scanner).
  let name = null;
  const nl = NAME_LABEL_RE.exec(raw); if (nl) name = nl[1].trim();
  if (!name) {
    const vfirst = a.vendor ? a.vendor.toUpperCase().split(/\s+/)[0] : null;
    name = lines
      .filter(l => /[A-Za-z]/.test(l) && !/\d{3,}/.test(l) && l.replace(/[^A-Za-z]/g, '').length >= 4)
      .filter(l => !(vfirst && l.toUpperCase().includes(vfirst)))
      .filter(l => !DW_BRAND_RE.test(l))                                   // never read OUR OWN name as the pattern
      .filter(l => !(color && l.toLowerCase() === color.toLowerCase()))   // drop a line that's ONLY the color
      .filter(l => !COLOR_LABEL_RE.test(l) && !/^(colou?r|pattern|design|collection|sku|item|model|mfr)\b/i.test(l))
      .filter(l => !(sku && l.toUpperCase().replace(/\s+/g, '').includes(sku)))
      .sort((x, y) => y.length - x.length)[0] || null;
    if (name) {
      name = name.replace(/\s+/g, ' ').trim().slice(0, 40);
      // pattern names often embed the colorway ("Compass Clay") — strip a trailing color word.
      if (color) name = name.replace(new RegExp('\\s*\\b' + escapeRe(color) + '\\b\\s*$', 'i'), '').trim();
      if (!name) name = null;
    }
  }
  return { sku, model: model || null, name: name || null, color: color || null, barcode: a.barcode || null };
}

// Parse the OCR binary's "<heightThousandths>\t<text>" lines. Height = printed font
// size on the label (taller box = bigger print), which drives "largest letters first".
function parseOcrRows(stdout) {
  return (stdout || '').trim().split('\n').filter(Boolean).map(l => {
    const i = l.indexOf('\t');
    if (i < 0) return { h: 0, t: l };
    const tag = l.slice(0, i), rest = l.slice(i + 1);
    if (tag === 'BC') {                        // barcode line: "BC\t<payload>\t<symbology>"
      const j = rest.indexOf('\t');
      return j < 0 ? { h: 10000, t: rest, bc: true } : { h: 10000, t: rest.slice(0, j), bc: true, sym: rest.slice(j + 1) };
    }
    return { h: parseInt(tag, 10) || 0, t: rest };
  });
}

// Pure: OCR rows → { text, vendor, candidates, top, topStrong }. No I/O.
function analyzeOcr(rows) {
  // Barcode payloads are ground truth (exact SKU) — they outrank every OCR'd code.
  const barcodes = rows.filter(r => r.bc).map(r => r.t.toUpperCase().replace(/\s+/g, '')).filter(b => b.length >= 4);
  const text = rows.filter(r => !r.bc).map(r => r.t).join('\n');
  const vlex = LEXICON.find(v => v.re.test(text.toUpperCase())) || null;
  const vendor = vlex ? vlex.name : null;
  // SKU-likeness tie-breaker: brand-matching prefix first (the swatch told us the vendor),
  // then known prefixes (GRS- = Fentucci, WD = Winfield Thybony), dashed/alnum, length.
  const skuScore = t =>
    ((vlex && vlex.pfx.test(t)) ? 140 : 0) +  // matches the brand printed on the swatch
    (t.startsWith('GRS-') ? 100 : 0) +
    (/^WD[A-Z]*\d/.test(t) ? 60 : 0) +        // Winfield Thybony WDW2310, WD-prefixed
    (t.includes('-') ? 12 : 0) +
    (/[A-Z]/.test(t) && /\d/.test(t) ? 20 : 0) +
    Math.min(t.length, 12);
  // CODE NUMBERS ONLY — alnum tokens holding a digit (WDW2310, GRS-26220, 5255-16). Plain
  // names and marketing prose are intentionally ignored: scan the code, never the text.
  const dwLabel = DW_BRAND_RE.test(text) || text.replace(/\D/g, '').includes(DW_PHONE);  // it's OUR sticker
  const seen = new Set(); const codes = [];
  for (const r of rows) {
    if (r.bc) continue;
    for (const t of (r.t.toUpperCase().match(CODE_TOKEN) || [])) {
      if (!/\d/.test(t) || t.length < 4 || seen.has(t)) continue;   // must hold a digit, >=4 chars
      if (isDwNoise(t, dwLabel)) continue;                          // our phone / phone-shaped → never a SKU
      seen.add(t); codes.push({ t, h: r.h, s: skuScore(t) });
    }
  }
  // LARGEST CODE FIRST: font height, then SKU-likeness (vendor prefix), then length.
  codes.sort((a, b) => (b.h - a.h) || (b.s - a.s) || (b.t.length - a.t.length));
  const hMax = codes.reduce((m, c) => Math.max(m, c.h), 0);
  // Candidate order: barcode payloads (ground truth) first, then text codes largest-first.
  const candidates = [...new Set([...barcodes, ...codes.map(c => c.t)])];
  const top = candidates[0] || null;
  const tc = codes[0] || null;
  // STRONG = safe to LOCK on the first sighting: a barcode (always), a known SKU prefix,
  // a brand-prefix match, or a brand on the swatch + the font-dominant code.
  const topStrong = !!top && (
    barcodes.length > 0 ||
    STRONG_PFX.test(top) || (vlex && vlex.pfx.test(top)) || (!!vendor && tc && tc.h >= hMax)
  );
  // FRONT (pattern face) vs BACK (printed label): a sample's label side is text/SKU/barcode-dense;
  // the pattern face reads as little/no printed text. Cheap ($0) — derived from the OCR already run,
  // so the live scanner can tell the user "flip to the label" instead of silently finding nothing.
  const textLen = text.replace(/\s+/g, '').length;
  const lineCount = rows.filter(r => !r.bc && String(r.t).trim()).length;
  let side = 'unknown', sideConf = 0.4, sideReason = 'ambiguous';
  if (barcodes.length) { side = 'back'; sideConf = 0.98; sideReason = 'barcode present'; }
  else if (codes.length && (vendor || lineCount >= 2)) { side = 'back'; sideConf = 0.9; sideReason = 'SKU code + ' + (vendor ? 'vendor name' : 'printed text'); }
  else if (lineCount >= 3 || textLen >= 24) { side = 'back'; sideConf = 0.72; sideReason = 'dense printed text'; }
  else if (!codes.length && lineCount <= 1 && textLen < 8) { side = 'front'; sideConf = 0.8; sideReason = 'little/no printed text (pattern face)'; }
  const out = { text, vendor, candidates, top, topStrong, barcode: barcodes[0] || null,
    side, side_confidence: sideConf, side_reason: sideReason };
  // Human-facing fields the live camera highlights in green as they're detected.
  out.fields = classifyFields(rows, out);
  return out;
}


// ── Vendor sample profiles: "how each vendor labels their samples" ─────────────
// Built broadly from the catalog by build_vendor_profiles.py and refined per-scan by
// /api/learn. Merged with the curated seed VENDOR_LEX into the runtime LEXICON the
// scanner consults — so brand detection + SKU prefix-boosting improve as we learn.
const VENDOR_PROFILES_FILE = path.join(DATA, 'vendor_profiles.json');
const PRIVATE_LABEL_RE = /BREWSTER|YORK|WALLQUEST|CHESAPEAKE|NEXTWALL|SEABROOK|COMMAND ?54|DESIMA|CARLSTEN|NICOLETTE ?MAYER/i;
const escapeRe = s => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function brandRe(name) {                  // vendor name -> flexible OCR detection source string
  const words = (String(name).match(/[A-Za-z]+/g) || []).filter(w => w.length >= 3).map(w => w.toUpperCase());
  if (!words.length) return null;
  if (words.length === 1 && words[0].length < 4) return null;   // single short word = noisy
  return words.map(escapeRe).join('\\s*');
}
let VENDOR_PROFILES = (loadJSON(VENDOR_PROFILES_FILE, {}).profiles) || {};
function saveVendorProfiles() {
  const meta = loadJSON(VENDOR_PROFILES_FILE, {})._meta || {};
  meta.updated_at = new Date().toISOString(); meta.vendors = Object.keys(VENDOR_PROFILES).length;
  saveJSON(VENDOR_PROFILES_FILE, { _meta: meta, profiles: VENDOR_PROFILES });
}
// Compile one learned profile into the seed's {name, re, pfx} shape.
function profileToLex(name, p) {
  let re = null; try { re = p.alias_re ? new RegExp(p.alias_re) : null; } catch (e) { re = null; }
  // Require >=2 supporting scans before a prefix biases ranking, so ONE bad scan can't inject
  // a wrong prefix into the lexicon. Catalog-built prefixes are already vetted (builder keeps
  // n>=3), so this only gates thin learned ones until a 2nd scan confirms them.
  const prefs = Object.keys(p.prefixes || {}).filter(k => k && (p.prefixes[k] || 0) >= 2).sort((a, b) => b.length - a.length); // WDW before WD
  // A vendor can be BOTH prefixed AND numeric-dominant (Schumacher: DWLK house code + bare
  // numbers on the swatch) — express both so the scanner matches either form it sees.
  const alts = prefs.map(escapeRe);
  if ((p.numeric_share || 0) >= 0.3) alts.push('\\d{3,}');
  let pfx = null;
  if (alts.length) { try { pfx = new RegExp('^(' + alts.join('|') + ')'); } catch (e) { pfx = null; } }
  if (!re && !pfx) return null;
  return { name, re: re || /(?!)/, pfx: pfx || /(?!)/, learned: true, private_label: !!p.private_label };
}
// LEXICON = curated seed first (trusted), then learned profiles not already seeded.
let LEXICON = [];
function rebuildLexicon() {
  const seeded = new Set(VENDOR_LEX.map(v => v.name.toUpperCase()));
  const learned = Object.entries(VENDOR_PROFILES).map(([n, p]) => profileToLex(n, p))
    .filter(Boolean).filter(e => !seeded.has(e.name.toUpperCase()));
  LEXICON = VENDOR_LEX.concat(learned);
}
rebuildLexicon();

// ── Local VLM brand/logo recognition (exo ring via shared lib, Gemini fallback) ─────
// Reads a STYLIZED logo/wordmark + typesetting that the deterministic OCR can't, to name
// the vendor when no brand text was cleanly recognized. An on-lock assist, not per-frame.
// TK-12090 Lane P: the old path POSTed to a local Ollama (:11434, qwen2.5vl:7b) that was
// RETIRED 2026-09-18 — every call silently errored. It now goes through the shared fleet
// vision lib (~/Projects/_shared/lib/exo-vision.mjs): exo ring primary ($0), Gemini fallback
// (cost-logged), and an honest {error, not_measured} when neither is reachable. The lib is
// ESM; this file is CJS, so it is loaded lazily via dynamic import(). Override the path
// with EXO_VISION_LIB (e.g. on a host where _shared lives elsewhere).
const EXO_VISION_LIB = process.env.EXO_VISION_LIB || path.join(__dirname, '..', '_shared', 'lib', 'exo-vision.mjs');
let _exoLibP = null;
function exoLib() {
  if (!_exoLibP) _exoLibP = import(require('url').pathToFileURL(EXO_VISION_LIB).href)
    .catch(e => { _exoLibP = null; throw new Error(`vision lib unavailable (${EXO_VISION_LIB}): ${e.message}`); });
  return _exoLibP;
}
// The ring model is chosen by the lib (VISION_MODEL env, or whichever vision instance is live).
// 'fast'/'accurate' and legacy Ollama names are accepted for API compatibility but no longer
// select a different model — there is only the ring's vision instance (or the Gemini fallback).
const OLLAMA_VISION_MODEL = process.env.VISION_MODEL || 'mlx-community/Qwen3-VL-4B-Instruct-4bit';   // name kept for callers; reports the ring model
function pickVisionModel() { return OLLAMA_VISION_MODEL; }
// Strip ```json fences a chat model may wrap its JSON in, so callers can JSON.parse(r.response).
function _unfence(t) { const m = String(t || '').match(/```(?:json)?\s*([\s\S]*?)```/i); return (m ? m[1] : String(t || '')).trim(); }
// Returns the Ollama-compatible shape callers already parse: { response, error } plus
// provider/model/cost_usd so the $ is visible (Steve's always-show-costs rule).
async function ollamaVision(b64, prompt, timeoutMs, model) {
  try {
    const lib = await exoLib();
    const r = await lib.visionChat({ prompt, image: { b64, mime: 'image/jpeg' }, model: model || OLLAMA_VISION_MODEL,
      timeoutMs: timeoutMs || 60000, maxTokens: 512 });
    if (!r.ok) return { error: r.error || 'vision not measured', not_measured: true, provider: null, cost_usd: 0 };
    return { response: _unfence(r.text), provider: r.provider, model: r.model, cost_usd: r.cost_usd };
  } catch (e) { return { error: e.message, not_measured: true, provider: null, cost_usd: 0 }; }
}

// ── Pluggable vision engines: macvision (Apple Vision OCR) · exo (VLM ring, reasoning) ·
// gemini · gcv ── Steve 2026-07-06: so the scanner runs on Kamatera (Linux), where the
// macOS `bin/ocr` Vision binary doesn't exist. Each engine is selectable per request
// ({engine:"gemini"} or ?engine=gcv); engine:"all" fans out to every AVAILABLE engine and
// returns each result for comparison. Default = macvision on macOS, gemini elsewhere.
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || '';
const GEMINI_VISION_MODEL = process.env.GEMINI_VISION_MODEL || 'gemini-2.5-flash';
const GCV_API_KEY = process.env.GCV_API_KEY || process.env.GOOGLE_VISION_API_KEY || '';
const IS_DARWIN = process.platform === 'darwin';
const HAS_MACVISION = IS_DARWIN && fs.existsSync(path.join(ROOT, 'bin/ocr'));
// TK-12090: 'exo' is a DISTINCT engine from 'macvision' — it maps to the reasoning/identify
// VLM ring (ollamaVision → exoLib, below) and is cross-platform (unlike bin/ocr, which is
// macOS-only and stays the literal-OCR meaning of 'macvision'). "Available" here means
// CONFIGURED (the shared lib is present on this host), matching HAS_MACVISION's own static
// fs.existsSync check — live reachability (ring up/down, Gemini fallback, honest
// not_measured) is handled per-call inside exo-vision.mjs's visionChat(), not here.
const HAS_EXO = fs.existsSync(EXO_VISION_LIB);
// Prefer gemini whenever a key is present: macvision's identify path routes to the
// retired Ollama (qwen2.5vl), so gemini is the only working OCR engine. Override with
// OCR_ENGINE=macvision to force the local path back. (Steve chose gemini, 2026-09-18.)
const DEFAULT_ENGINE = (process.env.OCR_ENGINE || (GEMINI_API_KEY ? 'gemini' : (HAS_MACVISION ? 'macvision' : 'gemini'))).toLowerCase();
const ALL_ENGINES = ['macvision', 'exo', 'gemini', 'gcv'];
// Rough per-scan cost so the caller always sees the $ (Steve's "always show costs" rule).
// exo is $0 when the ring serves it; if it silently falls back to Gemini inside the lib,
// the REAL cost comes back on r.cost_usd from ollamaVision (this table is only the display
// default before a call has run — see /api/ocr and /api/identify's cost_usd fields).
const ENGINE_COST_USD = { macvision: 0, exo: 0, gemini: 0.0006, gcv: 0.0015 };
function engineAvailable(e) {
  if (e === 'macvision') return HAS_MACVISION;
  if (e === 'exo') return HAS_EXO;
  if (e === 'gemini') return !!GEMINI_API_KEY;
  if (e === 'gcv') return !!GCV_API_KEY;
  return false;
}
const availableEngines = () => ALL_ENGINES.filter(engineAvailable);
// Resolve a requested engine to the concrete engine(s) to run. 'all' => every available one;
// an unavailable/unknown pick falls back to the default (then any available) so a scan never dead-ends.
function resolveEngines(req) {
  const want = String(req || DEFAULT_ENGINE).toLowerCase().trim();
  if (want === 'all') return availableEngines();
  if (engineAvailable(want)) return [want];
  if (engineAvailable(DEFAULT_ENGINE)) return [DEFAULT_ENGINE];
  return availableEngines().slice(0, 1);
}

// FUSED back-label OCR: run every available engine (Google Cloud Vision = literal char fidelity, best
// for exact codes; Gemini = layout/context) and UNION their candidate codes. The catalog's partial +
// trim-tolerant search then recovers a mostly-right read, so more engines = more shots on goal. Engine
// order is GCV → gemini → macvision (literal transcribers first). Plug-and-play: keying GCV_API_KEY auto-
// enables it here with no code change. Cost = sum of the engines that run (shown per scan).
// Client now sends the RAW camera photo (no client-side decode — iOS freeze fix). Normalize server-side:
// ImageMagick converts ANY format (HEIC/JPEG/PNG), auto-orients, and downscales to ≤3000px JPEG so OCR
// engines (GCV needs JPEG/PNG, not HEIC) + storage get a sane image. Best-effort — original on failure.
// TK-12224 (grainy Shopify product photos): this same normalize path is also the LAST step before the
// image is attached to a live Shopify product (createNewItem / /api/photo / /api/photos all pipe their
// b64 through normB64 below). The client already caps + compresses once (index.html tsCapture / cam.html
// both cap the longest edge to 2000px at JPEG quality 0.92; batch.html's master is native-res at 0.92).
// The old 1600x1600 + quality 85 here was a SECOND, LOWER-quality JPEG generation stacked on top of the
// client's own compression — for busy, high-frequency wallcovering/fabric patterns that shows up exactly
// as visible blocking/mosquito-noise ("grainy"). Raising the cap above every client's output makes this
// step a no-op for size (">" never upscales) and matching the quality factor to the client's 0.92 removes
// the double-compression quality loss, while still normalizing HEIC/orientation/format for OCR + storage.
function normalizeImage(buf) {
  return new Promise(resolve => {
    let out = [];
    const cp = execFile('/usr/bin/convert', ['-', '-auto-orient', '-resize', '3000x3000>', '-quality', '92', 'jpg:-'],
      { maxBuffer: 48 * 1024 * 1024, timeout: 12000, encoding: 'buffer' }, (err, stdout) => {
        resolve(err || !stdout || !stdout.length ? buf : stdout);   // fall back to the original bytes
      });
    try { cp.stdin.on('error', () => {}); cp.stdin.write(buf); cp.stdin.end(); } catch (e) { resolve(buf); }
  });
}
// normalize a base64 image (any format/size, incl. iOS HEIC) → clean ≤3000px JPEG base64. The client now
// sends RAW camera photos (no client decode — iOS freeze fix), so EVERY ingest point normalizes here.
async function normB64(b64) { if (!b64) return b64; try { return (await normalizeImage(Buffer.from(b64, 'base64'))).toString('base64'); } catch (e) { return b64; } }
const OCR_FUSION_ORDER = ['gcv', 'gemini', 'macvision'];
async function backOcrFused(buf) {
  const engines = OCR_FUSION_ORDER.filter(engineAvailable);
  if (!engines.length) return null;
  const results = (await Promise.all(engines.map(e => runOcr(e, buf).catch(() => null)))).filter(r => r && r.ok);
  if (!results.length) return null;
  const candidates = [], seen = new Set();
  for (const r of results)                                   // GCV candidates lead (literal reads first)
    for (const c of [r.barcode, r.top, ...(r.candidates || [])].filter(Boolean)) {
      const u = String(c).toUpperCase(); if (!seen.has(u)) { seen.add(u); candidates.push(u); }
    }
  const pick = k => { for (const r of results) { const v = r[k]; if (v) return v; } return null; };
  const fields = {}; for (const r of results) { const f = r.fields || {}; for (const k of ['sku', 'model', 'name', 'color', 'barcode']) if (!fields[k] && f[k]) fields[k] = f[k]; }
  return { ok: true, engines: results.map(r => r.engine), top: candidates[0] || null, candidates,
    vendor: pick('vendor'), barcode: pick('barcode'), fields, cost_usd: results.reduce((s, r) => s + (r.cost_usd || 0), 0) };
}

// Gemini multimodal (OCR + reasoning identify). Returns { response:<text>, model, error }
// — `response` mirrors Ollama's field so JSON.parse(r.response) callers work unchanged.
function geminiVision(b64, prompt, timeoutMs) {
  return new Promise(resolve => {
    if (!GEMINI_API_KEY) return resolve({ error: 'no GEMINI_API_KEY', model: GEMINI_VISION_MODEL });
    let target; try { target = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_VISION_MODEL}:generateContent?key=${encodeURIComponent(GEMINI_API_KEY)}`); }
    catch (e) { return resolve({ error: 'bad gemini url', model: GEMINI_VISION_MODEL }); }
    const payload = JSON.stringify({ contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: 'image/jpeg', data: b64 } }] }],
      generationConfig: { temperature: 0, responseMimeType: 'application/json' } });
    const r = https.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: timeoutMs || 30000 },
      res => { let d = ''; res.on('data', c => d += c); res.on('end', () => {
        try {
          const j = JSON.parse(d);
          if (j.error) return resolve({ error: j.error.message || 'gemini error', model: GEMINI_VISION_MODEL });
          const parts = (j.candidates && j.candidates[0] && j.candidates[0].content && j.candidates[0].content.parts) || [];
          resolve({ response: parts.map(x => x.text || '').join(''), model: GEMINI_VISION_MODEL });
        } catch (e) { resolve({ error: 'parse', model: GEMINI_VISION_MODEL }); } }); });
    r.on('error', e => resolve({ error: e.message, model: GEMINI_VISION_MODEL }));
    r.on('timeout', () => { r.destroy(); resolve({ error: 'timeout', model: GEMINI_VISION_MODEL }); });
    r.write(payload); r.end();
  });
}

// Fetch a catalog image URL → base64 (bounded), so it can be shown to the vision model. $0.
function fetchImageB64(url, maxBytes) {
  return new Promise(resolve => {
    let u; try { u = new URL(url); } catch (e) { return resolve(null); }
    if (u.protocol !== 'https:' && u.protocol !== 'http:') return resolve(null);
    // ask Shopify CDN for a small variant to keep it light
    if (/shopify/i.test(u.hostname) && !/[?&]width=/.test(u.href)) u.href += (u.search ? '&' : '?') + 'width=400';
    const lib = u.protocol === 'https:' ? https : http;
    const rq = lib.get(u.href, { timeout: 6000 }, res => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); return resolve(fetchImageB64(res.headers.location, maxBytes)); }
      if (res.statusCode !== 200) { res.resume(); return resolve(null); }
      const chunks = []; let n = 0;
      res.on('data', c => { n += c.length; if (n > (maxBytes || 3e6)) { rq.destroy(); resolve(null); } else chunks.push(c); });
      res.on('end', () => resolve(Buffer.concat(chunks).toString('base64')));
    });
    rq.on('error', () => resolve(null)); rq.on('timeout', () => { rq.destroy(); resolve(null); });
  });
}

// VISION MATCH: show Gemini the user's ACTUAL photo + candidate catalog images and have it pick the
// one that is the SAME pattern by pixels (not typed attributes). images = [b64,...]; returns
// { best: <0-based idx into images, or -1>, confidence }.
function geminiVisualMatch(userB64, images) {
  return new Promise(resolve => {
    if (!GEMINI_API_KEY || !images.length) return resolve(null);
    const parts = [{ text: `Image 1 is a PHOTO of a physical wallcovering/fabric SAMPLE. The remaining images (2 to ${images.length + 1}) are catalog product photos. Which ONE catalog image is the SAME pattern/material as the sample in image 1? Judge only the actual visual pattern, texture and colors — ignore crop, lighting, angle, and background. Reply ONLY compact JSON: {"match": <the catalog image number 2-${images.length + 1}, or 0 if none clearly match>, "confidence": <0-1>}.` },
      { inline_data: { mime_type: 'image/jpeg', data: userB64 } }];
    images.forEach(im => parts.push({ inline_data: { mime_type: 'image/jpeg', data: im } }));
    let target; try { target = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_VISION_MODEL}:generateContent?key=${encodeURIComponent(GEMINI_API_KEY)}`); }
    catch (e) { return resolve(null); }
    const payload = JSON.stringify({ contents: [{ parts }], generationConfig: { temperature: 0, responseMimeType: 'application/json' } });
    const rq = https.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: 45000 },
      res => { let d = ''; res.on('data', c => d += c); res.on('end', () => {
        try { const j = JSON.parse(d);
          const t = ((j.candidates && j.candidates[0] && j.candidates[0].content && j.candidates[0].content.parts) || []).map(x => x.text || '').join('');
          const o = JSON.parse(t); const num = parseInt(o.match, 10);
          resolve({ best: (num >= 2 ? num - 2 : -1), confidence: (o.confidence ?? null) });
        } catch (e) { resolve(null); } }); });
    rq.on('error', () => resolve(null)); rq.on('timeout', () => { rq.destroy(); resolve(null); });
    rq.write(payload); rq.end();
  });
}

// Google Cloud Vision DOCUMENT_TEXT_DETECTION (OCR only — no reasoning). Returns { text, error }.
function gcvOcr(b64, timeoutMs) {
  return new Promise(resolve => {
    if (!GCV_API_KEY) return resolve({ error: 'no GCV_API_KEY' });
    let target; try { target = new URL(`https://vision.googleapis.com/v1/images:annotate?key=${encodeURIComponent(GCV_API_KEY)}`); }
    catch (e) { return resolve({ error: 'bad gcv url' }); }
    const payload = JSON.stringify({ requests: [{ image: { content: b64 }, features: [{ type: 'DOCUMENT_TEXT_DETECTION' }] }] });
    const r = https.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: timeoutMs || 20000 },
      res => { let d = ''; res.on('data', c => d += c); res.on('end', () => {
        try {
          const j = JSON.parse(d);
          if (j.error) return resolve({ error: j.error.message || 'gcv error' });
          const r0 = (j.responses && j.responses[0]) || {};
          if (r0.error) return resolve({ error: r0.error.message || 'gcv image error' });
          resolve({ text: (r0.fullTextAnnotation && r0.fullTextAnnotation.text) || '' });
        } catch (e) { resolve({ error: 'parse' }); } }); });
    r.on('error', e => resolve({ error: e.message }));
    r.on('timeout', () => { r.destroy(); resolve({ error: 'timeout' }); });
    r.write(payload); r.end();
  });
}

// Cloud OCR returns a flat text block; synthesize analyzeOcr rows from it. Font height is
// unknown from a text-only read, so every line gets a uniform height — analyzeOcr then ranks
// by SKU-likeness (vendor prefix / dashes / length) instead of by font size. Barcodes aren't
// decoded by text OCR (macvision-only via Apple Vision), so cloud reads carry no `bc` ground-truth line.
function textToRows(text) {
  return String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean).map(l => ({ h: 100, t: l }));
}

// OCR one JPEG buffer with ONE engine → { engine, ok, cost_usd, ...analyzeOcr(), err }.
function runOcr(engine, buf) {
  return new Promise(resolve => {
    const cost = ENGINE_COST_USD[engine] || 0;
    const b64 = buf.toString('base64');
    if (engine === 'macvision') {
      const tmp = path.join(PHOTOS, `_ocr-${engine}-${Date.now()}.jpg`);
      try { fs.writeFileSync(tmp, buf); } catch (e) { return resolve({ engine, ok: false, cost_usd: cost, err: 'write fail' }); }
      return execFile(path.join(ROOT, 'bin/ocr'), [tmp], { timeout: 8000 }, (err, stdout) => {
        try { fs.unlinkSync(tmp); } catch (e) {}
        if (err) return resolve({ engine, ok: false, cost_usd: cost, err: err.message });
        resolve(Object.assign({ engine, ok: true, cost_usd: cost }, analyzeOcr(parseOcrRows(stdout))));
      });
    }
    const OCR_PROMPT = 'You are an OCR engine reading a wallcovering/fabric SAMPLE label. Transcribe EVERY piece of text you see, one text element per line. Preserve SKUs / model codes EXACTLY as printed — do NOT normalize O<->0 or I<->1, do not invent or correct anything. Reply ONLY as compact JSON: {"lines":["<line>","<line>"]}.';
    if (engine === 'gemini') {
      return geminiVision(b64, OCR_PROMPT, 30000).then(r => {
        if (r.error) return resolve({ engine, ok: false, cost_usd: cost, err: r.error });
        let lines = []; try { const j = JSON.parse(r.response || '{}'); lines = Array.isArray(j.lines) ? j.lines : []; } catch (e) { lines = String(r.response || '').split(/\r?\n/); }
        resolve(Object.assign({ engine, ok: true, cost_usd: cost }, analyzeOcr(textToRows(lines.join('\n')))));
      });
    }
    if (engine === 'gcv') {
      return gcvOcr(b64, 20000).then(r => {
        if (r.error) return resolve({ engine, ok: false, cost_usd: cost, err: r.error });
        resolve(Object.assign({ engine, ok: true, cost_usd: cost }, analyzeOcr(textToRows(r.text))));
      });
    }
    resolve({ engine, ok: false, cost_usd: 0, err: 'unknown engine' });
  });
}

// Reasoning vision (brand/pattern identify) for one engine. exo => the VLM ring (primary
// reasoning path, TK-12090); macvision => local Ollama (legacy macOS-only alias, kept for
// back-compat); gemini/gcv => Gemini (GCV is OCR-only, so identify always routes to Gemini
// there). Returns { response:<json-text>, model, provider, cost_usd, error } — provider/
// cost_usd come straight from exo-vision.mjs's visionChat() so the real serving engine
// (exo vs its Gemini fallback) and real $ are always visible, never assumed.
function visionJSON(engine, b64, prompt, opts) {
  opts = opts || {};
  if (engine === 'exo' || engine === 'macvision') return ollamaVision(b64, prompt, opts.timeoutMs, opts.model);
  return geminiVision(b64, prompt, opts.timeoutMs || 30000);
}

// Fuzzy-match a VLM brand guess to a real known vendor (the VLM mis-spells: "BRUNSWIG"
// -> "Brunschwig & Fils"). Normalized Levenshtein over seed + learned vendor names.
function _lev(a, b) {
  const m = a.length, n = b.length; if (!m) return n; if (!n) return m;
  let prev = Array.from({ length: n + 1 }, (_, j) => j), cur = new Array(n + 1);
  for (let i = 1; i <= m; i++) {
    cur[0] = i;
    for (let j = 1; j <= n; j++)
      cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
    [prev, cur] = [cur, prev];
  }
  return prev[n];
}
function fuzzyVendor(guess) {
  const g = String(guess || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
  if (g.length < 4) return null;
  const names = new Set([...VENDOR_LEX.map(v => v.name), ...Object.keys(VENDOR_PROFILES)]);
  const scored = [];
  for (const name of names) {
    const n = name.toUpperCase().replace(/[^A-Z0-9]/g, ''); if (n.length < 4) continue;
    const ratio = _lev(g, n) / Math.max(g.length, n.length);
    if (ratio < 0.34) scored.push({ name, n, ratio });
  }
  if (!scored.length) return null;
  scored.sort((a, b) => a.ratio - b.ratio);
  const best = scored[0];
  // AMBIGUITY GUARD: if a GENUINELY DIFFERENT vendor (different normalized name — so same-brand
  // spellings like "Armani Casa"/"Armani/Casa" don't count) is within MARGIN of the winner, the
  // read is ambiguous (e.g. "Campbell" matches both Alan Campbell and Nina Campbell) → don't guess.
  const rival = scored.find(s => s.n !== best.n);
  if (rival && (rival.ratio - best.ratio) < 0.08) return null;
  return best.name;
}

// DTD verdict A (2026-06-25, 3/3): fingerprint TIEBREAKER. Only consulted when the name
// match (lexicon + fuzzy) couldn't resolve a vendor — compares the live VLM read of the
// swatch logo (brand text + typeface) against the 20 stored, validated reference-logo
// fingerprints. Gated to the low-confidence branch so it can only help, never regress.
function fingerprintVendor(reads, typeface) {
  const nr = String(reads || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  const tf = String(typeface || '').toLowerCase().trim();
  if (nr.length < 4) return null;
  let best = null, bestScore = 0;
  for (const [name, p] of Object.entries(VENDOR_PROFILES)) {
    if (p.logo_valid !== true || !p.logo_reads) continue;     // only validated fingerprints
    const fr = String(p.logo_reads).toLowerCase().replace(/[^a-z0-9]/g, '');
    if (fr.length < 4) continue;
    // reads-as similarity: containment either way (the VLM phrasing varies run to run)
    let score = 0;
    if (fr === nr) score = 1.0;
    else if (fr.includes(nr) || nr.includes(fr)) score = 0.8;
    else { const sh = _lev(fr, nr) / Math.max(fr.length, nr.length); if (sh < 0.3) score = 0.6; }
    if (!score) continue;
    if (tf && p.logo_typeface && tf === String(p.logo_typeface).toLowerCase().trim()) score += 0.1; // typeface agreement
    if (score > bestScore) { bestScore = score; best = name; }
  }
  return bestScore >= 0.6 ? { vendor: best, via: 'fingerprint', score: Math.round(bestScore * 100) / 100 } : null;
}

// Pipe a remote image through same-origin so a <canvas> can read its pixels without a
// cross-origin taint. Shared by /api/current-image (server-derived src) and the
// host-whitelisted /api/imgproxy (client-supplied src). `headers` adds caching/CORS.
function pipeUpstreamImage(src, res, headers) {
  https.get(src, ir => {
    if ((ir.statusCode || 0) >= 400) { ir.resume(); return send(res, 502, { err: 'upstream ' + ir.statusCode }); }
    res.writeHead(200, Object.assign({ 'Content-Type': ir.headers['content-type'] || 'image/jpeg' }, headers || {}));
    ir.pipe(res);
  }).on('error', e => send(res, 502, { err: e.message }));
}

const appHandler = (req, res) => {
  // public (no-auth) paths: health + the home-screen-install assets iOS fetches without creds
  const PUBLIC = ['/healthz', '/icon-180.png', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png', '/manifest.webmanifest', '/apps/similar', '/apps/color-index', '/apps/color-widen', '/apps/color-dots'];
  const _p = req.url.split('?')[0];
  if (!PUBLIC.includes(_p) && !_p.startsWith('/marketing/') && !checkAuth(req)) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Photo Capture"' });
    return res.end('Auth required');
  }
  const u = new URL(req.url, `http://${req.headers.host}`);

  if (u.pathname === '/healthz') return send(res, 200, { ok: true });

  // TK-12228 test seam: under --test, the PAID / external identification endpoints (Gemini/GCV OCR,
  // CLIP, FileMaker, dw_unified vendor registry) answer with deterministic empty results, so a headless
  // browser can drive the whole front+back capture flow at $0 with no external reads or writes.
  if (TEST_MODE && req.method !== 'OPTIONS') {
    const STUB = {
      '/api/extract': { ok: true, fields: {}, test_stub: true },
      '/api/ocr': { ok: true, candidates: [], top: null, topStrong: false, test_stub: true },
      '/api/identify': { ok: true, found: false, candidates: [], test_stub: true },
      '/api/identify-multi': { ok: true, found: false, matches: [], results: [], test_stub: true },
      '/api/recognize': { ok: true, found: false, matches: [], test_stub: true },
      '/api/learn': { ok: true, test_stub: true },
      '/api/ask': { ok: true, answer: '(test mode)', test_stub: true },
      '/api/incoming-samples': { ok: true, samples: [], test_stub: true },
      '/api/vendors-registry': { ok: true, count: 2, test_stub: true, vendors: [
        { vendor: 'Test Vendor', real_vendor: 'Test Vendor', vid: 'TST', sku_prefix: 'TST-', sku_range_start: 1000, fm_vid: '999', private_label: false },
        { vendor: 'Fentucci', real_vendor: 'Fentucci', vid: 'TWIL', sku_prefix: 'GRS-', sku_range_start: 26000, fm_vid: '1', private_label: true }] },
    };
    if (STUB[u.pathname]) { req.resume(); return send(res, 200, STUB[u.pathname]); }
  }

  // ── Public marketing assets (email images) — TIGHTLY bounded static serve ──
  // No auth (allowed in the PUBLIC gate above). NOT a generic file server:
  //  · Fixed dir public-marketing/ — never user-controlled.
  //  · Basename-only + strict regex allowlist (images only); rejects any '..' / slash.
  //  · Resolved path must stay inside the dir (path-traversal guard).
  if (_p.startsWith('/marketing/')) {
    const name = _p.slice('/marketing/'.length);
    if (name.includes('/') || name.includes('..') ||
        !/^[A-Za-z0-9._-]+\.(gif|png|jpe?g|webp)$/.test(name)) {
      return send(res, 404, { err: 'not found' });
    }
    const MDIR = path.join(ROOT, 'public-marketing');
    const fp = path.join(MDIR, name);
    if (!fp.startsWith(MDIR + path.sep)) return send(res, 404, { err: 'not found' });
    return fs.readFile(fp, (e, buf) => {
      if (e) return send(res, 404, { err: 'not found' });
      const ext = name.split('.').pop().toLowerCase();
      const ct = { gif: 'image/gif', png: 'image/png', jpg: 'image/jpeg',
                   jpeg: 'image/jpeg', webp: 'image/webp' }[ext] || 'application/octet-stream';
      res.writeHead(200, {
        'Content-Type': ct,
        'Cache-Control': 'public, max-age=604800',
        'Access-Control-Allow-Origin': '*'
      });
      res.end(buf);
    });
  }

  // ── PDP CLIP "More like this" proxy (GATED-STEP-1, shipped) ─────────────────
  // Public (no basic-auth) but TIGHTLY bounded: NOT a generic proxy.
  //  · SSRF guard: fixed upstream host+path 127.0.0.1:9914/similar — never user-controlled.
  //  · Input allowlist: ONLY {dw_sku, hex, style, k}; k capped at 50. Anything else dropped.
  //  · Output allowlist: only public catalog fields (already visible on the storefront).
  //  · CORS: reflect an ALLOWED storefront origin only (Access-Control-Allow-Origin locked).
  // Port 9914 stays loopback-only; this is the ONLY externally reachable surface to it.
  if (u.pathname === '/apps/similar') {
    const ALLOWED_ORIGINS = new Set([
      'https://designerwallcoverings.com',
      'https://www.designerwallcoverings.com',
      'https://designer-laboratory-sandbox.myshopify.com'
    ]);
    const origin = req.headers.origin || '';
    const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
    const CORS = {
      'Access-Control-Allow-Origin': corsOrigin,
      'Vary': 'Origin',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type'
    };
    if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
    if (req.method !== 'POST') return send(res, 405, { err: 'POST required' }, CORS);
    let body = '';
    req.on('data', c => { body += c; if (body.length > 4096) req.destroy(); });
    req.on('end', async () => {
      let p; try { p = JSON.parse(body || '{}'); } catch (e) { return send(res, 400, { err: 'bad json' }, CORS); }
      const dw_sku = (typeof p.dw_sku === 'string') ? p.dw_sku.trim() : '';
      if (!dw_sku || dw_sku.length > 64) return send(res, 400, { err: 'dw_sku required' }, CORS);
      // allowlist + sanitize — ONLY these four fields ever reach the loopback service
      const hex = (typeof p.hex === 'string') ? p.hex.slice(0, 16) : '';
      const style = (typeof p.style === 'string') ? p.style.slice(0, 64) : '';
      const k = Math.max(1, Math.min(parseInt(p.k, 10) || 8, 50));
      const payload = JSON.stringify({ dw_sku, hex, style, k });
      try {
        const r = await fetch('http://127.0.0.1:9914/similar', {   // FIXED host+path — SSRF-safe
          method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload,
          signal: AbortSignal.timeout(8000)
        });
        const j = await r.json().catch(() => ({ results: [] }));
        // re-emit ONLY public-safe columns (defense in depth even though upstream is already scoped)
        const results = Array.isArray(j.results) ? j.results.map(x => ({
          dw_sku: x.dw_sku, image: x.image, pattern: x.pattern,
          vendor: x.vendor, handle: x.handle || null, score: x.score
        })) : [];
        return send(res, 200, { ok: true, results }, CORS);
      } catch (e) {
        return send(res, 502, { ok: false, err: 'similar upstream', results: [] }, CORS);
      }
    });
    return;
  }

  // ── PDP color-dot "index of many products within 10% tolerance" ─────────────
  // (GATED-STEP-2, staged) Steve 2026-07-09: "bring up an index of many images,
  // not just that exact hex. tolerance 10% for color to pull more."
  // A clicked palette hex resolves to a CATALOG-WIDE grid of products whose
  // dominant color is within a perceptual 10% tolerance (CIELAB ΔE76) of the hex,
  // drawn from data/color-index.json (one dominant color per ACTIVE product,
  // with PRECOMPUTED LAB; generated by scripts/build-color-index.cjs). No DB at
  // request time — the index is loaded once and held in memory.
  //  · Public (no basic-auth) but tightly bounded, same posture as /apps/similar.
  //  · Input allowlist: ONLY {hex, k}; k capped at 60. Nothing else is read.
  //  · Output allowlist: only public catalog fields already visible on the store.
  //  · CORS: reflect an ALLOWED storefront origin only.
  //  · 10% tolerance is ONE named constant (COLOR_INDEX_TOLERANCE_PCT) → ΔE ceiling.
  // ── Shop-by-Color WHEEL dot set (GET) ──
  // The data-driven color dots for /pages/shop-by-color: real DB colors only,
  // each >=20 patterns, ΔE-collapsed to be visually unique, ordered by qty.
  // Read-only GET (no input), public, same CORS posture as the color routes.
  if (u.pathname === '/apps/color-dots') {
    const ALLOWED_ORIGINS = new Set([
      'https://designerwallcoverings.com',
      'https://www.designerwallcoverings.com',
      'https://designer-laboratory-sandbox.myshopify.com'
    ]);
    const origin = req.headers.origin || '';
    const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
    const CORS = {
      'Access-Control-Allow-Origin': corsOrigin,
      'Vary': 'Origin',
      'Access-Control-Allow-Methods': 'GET, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
      'Cache-Control': 'public, max-age=3600'
    };
    if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
    if (req.method !== 'GET') return send(res, 405, { err: 'GET required' }, CORS);
    try {
      const d = loadColorDots();
      return send(res, 200, d, CORS);
    } catch (e) {
      return send(res, 503, { ok: false, err: 'color dots unavailable', dots: [] }, CORS);
    }
  }

  if (u.pathname === '/apps/color-index') {
    const ALLOWED_ORIGINS = new Set([
      'https://designerwallcoverings.com',
      'https://www.designerwallcoverings.com',
      'https://designer-laboratory-sandbox.myshopify.com'
    ]);
    const origin = req.headers.origin || '';
    const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
    const CORS = {
      'Access-Control-Allow-Origin': corsOrigin,
      'Vary': 'Origin',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type'
    };
    if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
    if (req.method !== 'POST') return send(res, 405, { err: 'POST required' }, CORS);
    let body = '';
    req.on('data', c => { body += c; if (body.length > 4096) req.destroy(); });
    req.on('end', () => {
      let p; try { p = JSON.parse(body || '{}'); } catch (e) { return send(res, 400, { err: 'bad json' }, CORS); }
      const rawHex = (typeof p.hex === 'string') ? p.hex.trim() : '';
      const hex = /^#?[0-9a-fA-F]{6}$/.test(rawHex) ? ('#' + rawHex.replace('#', '').toLowerCase()) : '';
      if (!hex) return send(res, 400, { err: 'hex required (#rrggbb)' }, CORS);
      const k = Math.max(1, Math.min(parseInt(p.k, 10) || 36, 60));
      // ── Coordinate filters (Steve TK-10085) ──────────────────────────────────
      // Narrow the color-matched set by product TYPE (wallcovering / fabric /
      // other) and USE (commercial vs residential). Both derive purely from each
      // item's product_type (it.p) — no index rebuild needed. Blank/unknown = the
      // "All" default (no filter). USE has an explicit commercial/contract signal;
      // "residential" is the honest complement (everything not commercial/contract).
      const rawType = (typeof p.type === 'string' ? p.type : '').toLowerCase();
      const rawUse  = (typeof p.use  === 'string' ? p.use  : '').toLowerCase();
      const CI_TYPE = ['wallcovering', 'fabric', 'other'].indexOf(rawType) >= 0 ? rawType : '';
      const CI_USE  = ['commercial', 'residential'].indexOf(rawUse) >= 0 ? rawUse : '';
      const ciKeep = (ptype) => {
        if (!CI_TYPE && !CI_USE) return true;
        const s = (ptype || '').toLowerCase();
        const isWall = /wallcover|wallpaper|mural/.test(s);
        const isFab  = /fabric|upholst|drapery|multipurpose|pillow/.test(s);
        if (CI_TYPE === 'wallcovering' && !isWall) return false;
        if (CI_TYPE === 'fabric' && !isFab) return false;
        if (CI_TYPE === 'other' && (isWall || isFab)) return false;
        const isCommercial = /commercial|contract/.test(s);
        if (CI_USE === 'commercial' && !isCommercial) return false;
        if (CI_USE === 'residential' && isCommercial) return false;
        return true;
      };
      try {
        const idx = loadColorIndex();               // cached, loaded once
        const target = hexToLab(hex);
        const ceil = COLOR_INDEX_DELTA_E_CEILING;   // 10% → ΔE76 ceiling
        const within = [];
        for (let i = 0; i < idx.length; i++) {
          const it = idx[i];
          const dl = it.l - target.l, da = it.a - target.a, db = it.b - target.b;
          const de = Math.sqrt(dl * dl + da * da + db * db);
          if (de <= ceil && ciKeep(it.p)) within.push({ it, de });
        }
        within.sort((x, y) => x.de - y.de);          // nearest first
        const results = within.slice(0, k).map(w => ({
          handle: w.it.h, title: w.it.t, vendor: w.it.v,
          hex: w.it.x, image: w.it.i, product_type: w.it.p || '',
          delta_e: Math.round(w.de * 10) / 10
        }));
        return send(res, 200, {
          ok: true, hex, tolerance_pct: COLOR_INDEX_TOLERANCE_PCT,
          delta_e_ceiling: ceil, total_in_tolerance: within.length, results
        }, CORS);
      } catch (e) {
        return send(res, 503, { ok: false, err: 'color index unavailable', results: [] }, CORS);
      }
    });
    return;
  }

  // ── Shop-by-Color WHEEL "wider band" index (designerwallcoverings.com/pages/colors) ──
  // Same in-memory color index + ΔE76 math as /apps/color-index, but with a wider,
  // caller-tunable tolerance (default 15% ≈ ΔE 15) and a higher result cap, so the
  // public color-wheel page can surface a BIG set of matches per hue (the PDP dots
  // stay on the tighter 10%/k60 path — that route is untouched). Read-only, public,
  // same CORS posture. Input allowlist: ONLY {hex, k, ceiling}.
  if (u.pathname === '/apps/color-widen') {
    const ALLOWED_ORIGINS = new Set([
      'https://designerwallcoverings.com',
      'https://www.designerwallcoverings.com',
      'https://designer-laboratory-sandbox.myshopify.com'
    ]);
    const origin = req.headers.origin || '';
    const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
    const CORS = {
      'Access-Control-Allow-Origin': corsOrigin,
      'Vary': 'Origin',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type'
    };
    if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
    if (req.method !== 'POST') return send(res, 405, { err: 'POST required' }, CORS);
    let body = '';
    req.on('data', c => { body += c; if (body.length > 4096) req.destroy(); });
    req.on('end', () => {
      let p; try { p = JSON.parse(body || '{}'); } catch (e) { return send(res, 400, { err: 'bad json' }, CORS); }
      const rawHex = (typeof p.hex === 'string') ? p.hex.trim() : '';
      const hex = /^#?[0-9a-fA-F]{6}$/.test(rawHex) ? ('#' + rawHex.replace('#', '').toLowerCase()) : '';
      if (!hex) return send(res, 400, { err: 'hex required (#rrggbb)' }, CORS);
      const k = Math.max(1, Math.min(parseInt(p.k, 10) || 240, 400));
      const ceil = Math.max(5, Math.min(Number(p.ceiling) || 15, 25)); // % ≈ ΔE76 ceiling
      const min = Math.max(0, Math.min(parseInt(p.min, 10) || 0, 400)); // guarantee at least this many
      try {
        const idx = loadColorIndex();               // cached, loaded once
        const target = hexToLab(hex);
        const scored = [];                           // ΔE for EVERY product, so we can
        for (let i = 0; i < idx.length; i++) {       // fall back to nearest-N when the band is thin
          const it = idx[i];
          const dl = it.l - target.l, da = it.a - target.a, db = it.b - target.b;
          scored.push({ it, de: Math.sqrt(dl * dl + da * da + db * db) });
        }
        scored.sort((x, y) => x.de - y.de);          // nearest first
        let sel = scored.filter(s => s.de <= ceil);  // within the 15% band
        const withinCeiling = sel.length;
        if (sel.length < min) sel = scored.slice(0, min); // thin band → expand to the nearest `min`
        const results = sel.slice(0, k).map(w => ({
          handle: w.it.h, title: w.it.t, vendor: w.it.v,
          hex: w.it.x, image: w.it.i, product_type: w.it.p || '',
          delta_e: Math.round(w.de * 10) / 10
        }));
        return send(res, 200, {
          ok: true, hex, tolerance_pct: ceil / 100, delta_e_ceiling: ceil, min,
          total_in_tolerance: withinCeiling, returned: results.length, results
        }, CORS);
      } catch (e) {
        return send(res, 503, { ok: false, err: 'color index unavailable', results: [] }, CORS);
      }
    });
    return;
  }

  // ── Remote-shutter pairing routes ──
  // SSE stream for the PHONE cam page. Registers the cam role; listens for `shoot`.
  if (u.pathname === '/cam/events') {
    sseInit(res);
    ROOM.cam = { res, lastSeen: Date.now() };
    sseSend('cam', 'status', pairStatus());
    broadcastStatus();
    req.on('close', () => { if (ROOM.cam && ROOM.cam.res === res) { ROOM.cam = null; ROOM.camLive = false; broadcastStatus(); } });
    return;
  }
  // SSE stream for the DESKTOP control page. Registers the desk role; listens for `shot`/`status`.
  if (u.pathname === '/desk/events') {
    sseInit(res);
    ROOM.desk = { res, lastSeen: Date.now() };
    sseSend('desk', 'status', pairStatus());
    broadcastStatus();
    req.on('close', () => { if (ROOM.desk && ROOM.desk.res === res) { ROOM.desk = null; broadcastStatus(); } });
    return;
  }
  // Phone reports its camera state (live/info) + keeps its lastSeen fresh.
  if (u.pathname === '/api/pair/cam-status' && req.method === 'POST') {
    let body = ''; req.on('data', c => body += c);
    req.on('end', () => {
      let p; try { p = JSON.parse(body); } catch (e) { p = {}; }
      if (ROOM.cam) ROOM.cam.lastSeen = Date.now();
      ROOM.camLive = !!p.live; ROOM.camInfo = String(p.info || '');
      broadcastStatus();
      return send(res, 200, { ok: true });
    });
    return;
  }
  // Desktop presses SHOOT → push a `shoot` event to the phone with the target SKU context.
  // This does NOT itself write to Shopify — the phone captures + uploads via /api/photo,
  // which keeps the exact same gated attach behavior as a manual capture today.
  if (u.pathname === '/api/pair/shoot' && req.method === 'POST') {
    let body = ''; req.on('data', c => body += c);
    req.on('end', () => {
      let p; try { p = JSON.parse(body); } catch (e) { p = {}; }
      if (ROOM.desk) ROOM.desk.lastSeen = Date.now();
      const st = pairStatus();
      if (!st.cam_connected) return send(res, 409, { err: 'phone not connected' });
      if (!st.cam_live) return send(res, 409, { err: 'phone camera not live — open /cam on the phone and allow the camera' });
      // target = the SKU the desktop selected (or none → phone just captures into the local gallery)
      const target = {
        dw_sku: p.dw_sku || null, product_id: p.product_id || null,
        keep_images: p.keep_images !== false, // default preserve existing imagery (safe)
        meta: p.meta || null, nonce: Date.now()
      };
      const ok = sseSend('cam', 'shoot', target);
      return send(res, ok ? 200 : 502, { ok, queued: ok });
    });
    return;
  }
  // Phone reports the result of a shot so the desktop can preview + see the attach outcome.
  if (u.pathname === '/api/pair/shot' && req.method === 'POST') {
    let body = ''; req.on('data', c => body += c);
    req.on('end', () => {
      let p; try { p = JSON.parse(body); } catch (e) { p = {}; }
      if (ROOM.cam) ROOM.cam.lastSeen = Date.now();
      ROOM.lastShot = Object.assign({ ts: new Date().toISOString() }, p);
      sseSend('desk', 'shot', ROOM.lastShot);
      return send(res, 200, { ok: true });
    });
    return;
  }
  // Status snapshot (polling fallback if a client's SSE is down).
  if (u.pathname === '/api/pair/status' && req.method === 'GET') return send(res, 200, pairStatus());

  // Camera capability probe page + its result logger (learn what an iPhone/iPad Safari exposes).
  if (u.pathname === '/caps') {
    const f = path.join(ROOT, 'public/caps.html');
    if (!fs.existsSync(f)) return send(res, 404, { err: 'caps.html missing' });
    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(fs.readFileSync(f, 'utf8'));
  }
  if (u.pathname === '/api/caps' && req.method === 'POST') {
    let body = ''; req.on('data', c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
    req.on('end', () => { try { fs.appendFileSync(path.join(ROOT, 'caps.log'), new Date().toISOString() + ' ' + body + '\n'); } catch (e) {} send(res, 200, { ok: true }); });
    return;
  }

  // Desktop control page + phone live-camera page (both behind the existing basic-auth).
  if (u.pathname === '/desk') {
    const f = path.join(ROOT, 'public/desk.html');
    if (!fs.existsSync(f)) return send(res, 404, { err: 'desk.html missing' });
    let html = fs.readFileSync(f, 'utf8').replace(/__VER__/g, buildLabel());
    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
  }
  if (u.pathname === '/cam') {
    const f = path.join(ROOT, 'public/cam.html');
    if (!fs.existsSync(f)) return send(res, 404, { err: 'cam.html missing' });
    let html = fs.readFileSync(f, 'utf8').replace(/__VER__/g, buildLabel());
    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
  }

  // ── TK-12162 "Instant Film" concept — additive, self-contained page, own route (mirrors /batch
  //    below). NOT wired into any nav/menu; reuses the EXISTING /api/extract + /api/create-item
  //    endpoints as-is. Not linked from index.html. The other three TK-12162 concept pages
  //    (minimal, wizard, probooth) are served by the same fixed allow-list.
  const _simple = /^\/(simple-(?:instant|minimal|wizard|probooth))(?:\.html)?$/.exec(u.pathname);
  if (_simple) {
    const f = path.join(ROOT, 'public', _simple[1] + '.html');
    if (!fs.existsSync(f)) return send(res, 404, { err: _simple[1] + '.html missing' });
    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(fs.readFileSync(f, 'utf8'));
  }

  // ── Capture history (TK-12228): every item's FRONT + BACK photos, newest first (behind Basic-auth).
  if (u.pathname === '/captures') {
    const f = path.join(ROOT, 'public/captures.html');
    if (!fs.existsSync(f)) return send(res, 404, { err: 'captures.html missing' });
    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(fs.readFileSync(f, 'utf8'));
  }
  if (u.pathname === '/api/captures' && req.method === 'GET') {
    const limit = Math.min(2000, Math.max(1, parseInt(u.searchParams.get('limit') || '500', 10) || 500));
    const all = listCaptures();
    // front/back items with no BACK (single-shot remote-cam/legacy 'photo'-only items have no pair to miss — same rule as captures.html)
    const missingBack = all.filter(c => c.photos.some(p => p.side === 'front') && !c.photos.some(p => p.side === 'back')).length;
    return send(res, 200, { ok: true, total: all.length, missing_back: missingBack, count: Math.min(limit, all.length), captures: all.slice(0, limit) });
  }

  // ── Per-vendor photo archive viewer (behind the global Basic-auth wall).
  if (u.pathname === '/vendor-photos') {
    const f = path.join(ROOT, 'public/vendor-photos.html');
    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(fs.readFileSync(f, 'utf8'));
  }
  if (u.pathname === '/api/vendor-photos' && req.method === 'GET') {
    backfillVendorPhotos();
    const rows = [];
    try {
      for (const line of fs.readFileSync(VENDOR_LOG, 'utf8').split('\n')) {
        if (!line) continue; try { rows.push(JSON.parse(line)); } catch (e) {}
      }
    } catch (e) { /* no log yet */ }
    const seen = new Set(), out = [];
    for (const r of rows) { if (seen.has(r.path)) continue; seen.add(r.path); out.push(r); }   // dedupe (backfill + re-archive)
    const vendors = {}; out.forEach(r => { vendors[r.vendor] = (vendors[r.vendor] || 0) + 1; });
    return send(res, 200, { ok: true, total: out.length, vendors, photos: out.reverse() });
  }

  // ── Batch Shoot Mode (production-line capture for 400+ samples) — additive, self-contained page.
  //    Runs the iPad's OWN camera (not the SSE-slaved /cam remote flow). See public/batch.html.
  if (u.pathname === '/batch') {
    const f = path.join(ROOT, 'public/batch.html');
    if (!fs.existsSync(f)) return send(res, 404, { err: 'batch.html missing' });
    let html = fs.readFileSync(f, 'utf8').replace(/__VER__/g, buildLabel());
    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
  }

  // ── /api/batch-shot: accept one batch capture's three variants and write them to disk under a
  //    per-session subdir as <SKU>_original.jpg / _master.jpg / _web.jpg. Idempotent by the filename
  //    (a re-shoot of the same SKU in the same session overwrites). Does NOT write to Shopify — batch
  //    capture is a cataloging step; a Shopify batch-attach is a separate v1.1 action. Body ≤ 90MB
  //    (3 JPEGs, base64-inflated). A per-session manifest.jsonl records every accepted shot.
  if (u.pathname === '/api/batch-shot' && req.method === 'POST') {
    let body = '';
    let _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 90 * 1024 * 1024) _big = true; });
    req.on('end', () => {
      if (_big) return send(res, 413, { ok: false, err: 'body too large' });
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      const { sessionId, sku, seq, vendor, collection, original, master, web, meta } = p || {};
      if (!sessionId || !sku) return send(res, 400, { err: 'sessionId + sku required' });
      if (!original && !master && !web) return send(res, 400, { err: 'at least one image variant required' });
      // sanitize to safe path segments (basename-only; reject traversal)
      const safeSession = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '').slice(0, 120);
      const safeSku = String(sku).replace(/[^A-Za-z0-9._-]/g, '').slice(0, 80);
      if (!safeSession || !safeSku) return send(res, 400, { err: 'bad sessionId/sku' });
      const dir = path.join(PHOTOS, 'batch', safeSession);
      if (!dir.startsWith(path.join(PHOTOS, 'batch') + path.sep)) return send(res, 400, { err: 'path' });
      try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { return send(res, 500, { err: 'mkdir failed' }); }
      const variants = { original, master, web };
      // Front/back pairing: a sample can be two shots sharing ONE SKU. Without a side in the
      // filename the second shot overwrites the first (<SKU>_original.jpg collides). When meta.side
      // is a known side, name files <SKU>_<side>_<variant>.jpg; a single unsided shot keeps the
      // legacy <SKU>_<variant>.jpg (back-compat with the office tool).
      const sidePart = (meta && typeof meta.side === 'string' && /^(psku|info|front|back)$/.test(meta.side)) ? meta.side + '_' : '';
      const paths = {}; const errors = [];
      for (const [k, v] of Object.entries(variants)) {
        if (!v) continue;
        let buf; try { buf = Buffer.from(String(v).replace(/^data:image\/\w+;base64,/, ''), 'base64'); }
        catch (e) { errors.push(k + ': decode'); continue; }
        if (buf.length < 200 || buf.length > 40 * 1024 * 1024) { errors.push(k + ': size ' + buf.length); continue; }
        const fname = `${safeSku}_${sidePart}${k}.jpg`;
        try { fs.writeFileSync(path.join(dir, fname), buf); paths[k] = `/photos/batch/${safeSession}/${fname}`;
              archiveVendorPhoto(path.join(dir, fname), { vendor, dw_sku: safeSku, source: 'batch', name: safeSession + '_' + fname }); }
        catch (e) { errors.push(k + ': write'); }
      }
      // durable per-session manifest (append-only) so a session is reconstructable off-device
      try {
        const mdir = path.join(DATA, 'batch-sessions'); fs.mkdirSync(mdir, { recursive: true });
        const rec = { at: new Date().toISOString(), sessionId: safeSession, sku: safeSku, seq: seq ?? null,
          vendor: vendor || null, collection: collection || null, paths, meta: meta || null };
        fs.appendFileSync(path.join(mdir, safeSession + '.jsonl'), JSON.stringify(rec) + '\n');
      } catch (e) { /* manifest is best-effort; the files are the source of truth */ }
      const ok = Object.keys(paths).length > 0;
      return send(res, ok ? 200 : 500, { ok, sku: safeSku, seq: seq ?? null, paths, errors });
    });
    return;
  }

  // ── Vendor-profile learning surface ──
  // What the app has learned about how each vendor labels their samples.
  if (u.pathname === '/api/vendor-profiles' && req.method === 'GET') {
    const vendors = Object.entries(VENDOR_PROFILES).map(([name, p]) => Object.assign({ name }, p))
      .sort((a, b) => (b.learned_scans || 0) - (a.learned_scans || 0) || (b.n || 0) - (a.n || 0));
    return send(res, 200, { total: vendors.length, seeded: VENDOR_LEX.map(v => v.name), vendors });
  }
  // Observability snapshot for the self-teaching scanner — read-only, in-memory. $0.
  if (u.pathname === '/api/stats' && req.method === 'GET') {
    const profs = Object.values(VENDOR_PROFILES);
    let lastLearn = null, learnTotal = 0;
    for (const p of profs) {
      learnTotal += (p.learned_scans || 0);
      if (p.learned_scans > 0 && p.updated_at && (!lastLearn || p.updated_at > lastLearn)) lastLearn = p.updated_at;
    }
    return send(res, 200, {
      version: buildLabel(),
      vendors: profs.length,
      seeded: VENDOR_LEX.length,
      fingerprinted: profs.filter(p => p.logo_valid === true).length,
      learned_scans_total: learnTotal,
      last_learn: lastLearn,
      recents: Object.keys(recents).length,
      catalog_indexed: CATALOG.length,
      vision_model: OLLAMA_VISION_MODEL,
    });
  }
  // Refine a vendor's profile from a scan that resolved to a real product. Conservative:
  // we only fold in structured signal (the on-swatch code prefix), never raw OCR prose.
  if (u.pathname === '/api/learn' && req.method === 'POST') {
    let body = ''; req.on('data', c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
    req.on('end', () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      const vendor = String(p.vendor || '').trim();
      if (vendor.length > 120) return send(res, 400, { err: 'vendor too long' });   // bound the regex rebuildLexicon compiles
      // reject prototype keys: VENDOR_PROFILES['__proto__'] returns Object.prototype (truthy),
      // so prof.prefixes[pre] would deref undefined and CRASH the process (DoS). No real vendor
      // name is one of these. (proto-pollution guard)
      if (vendor === '__proto__' || vendor === 'constructor' || vendor === 'prototype') return send(res, 400, { err: 'invalid vendor' });
      const sku = String(p.sku || '').toUpperCase().replace(/\s+/g, '');
      if (!vendor || !sku) return send(res, 400, { err: 'vendor + sku required' });
      const prof = VENDOR_PROFILES[vendor] || (VENDOR_PROFILES[vendor] = {
        n: 0, aliases: [vendor.toUpperCase()], alias_re: brandRe(vendor), prefixes: {},
        numeric_share: 0, common_len: 0, examples: [], private_label: PRIVATE_LABEL_RE.test(vendor),
        source: 'scan', learned_scans: 0
      });
      const pre = (sku.match(/^[A-Z]+/) || [''])[0];
      if (pre) prof.prefixes[pre] = (prof.prefixes[pre] || 0) + 1;
      else prof.numeric_share = Math.min(1, (prof.numeric_share || 0) + 0.05);
      if (!prof.examples.includes(sku) && prof.examples.length < 8) prof.examples.push(sku);
      // Learn the vendor's SKU SHAPE (WDW2310 → AAA####) so we recognize their code format on sight.
      const shape = sku.replace(/[A-Z]/g, 'A').replace(/[0-9]/g, '#');
      prof.sku_shapes = prof.sku_shapes || {}; prof.sku_shapes[shape] = (prof.sku_shapes[shape] || 0) + 1;
      // Save the vendor "specs" — the colorways + pattern names seen, so the reader learns each
      // vendor's vocabulary (bounded lists; deduped, case-normalized).
      const addTo = (key, val, cap) => {
        val = String(val || '').replace(/\s+/g, ' ').trim().slice(0, 40);
        if (!val) return; prof[key] = prof[key] || [];
        if (!prof[key].some(x => x.toLowerCase() === val.toLowerCase()) && prof[key].length < cap) prof[key].push(val);
      };
      if (p.color) addTo('colors', p.color, 40);
      if (p.name) addTo('names', p.name, 60);
      prof.learned_scans = (prof.learned_scans || 0) + 1;
      prof.updated_at = new Date().toISOString();
      saveVendorProfiles(); rebuildLexicon();
      return send(res, 200, { ok: true, vendor, learned_prefix: pre || null, learned_scans: prof.learned_scans,
        sku_shape: shape, colors: (prof.colors || []).length, names: (prof.names || []).length });
    });
    return;
  }
  // Learning viewer page (admin, behind the existing basic-auth).
  if (u.pathname === '/learn') {
    const f = path.join(ROOT, 'public/learn.html');
    if (!fs.existsSync(f)) return send(res, 404, { err: 'learn.html missing' });
    const html = fs.readFileSync(f, 'utf8').replace(/__VER__/g, buildLabel());
    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
  }

  // Recognize the BRAND by its logo/wordmark + typesetting via the local VLM (qwen2.5vl).
  // The assist for when OCR couldn't read the brand text but the logo is visible. $0 (local).
  if (u.pathname === '/api/identify' && req.method === 'POST') {
    let body = ''; req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
    req.on('end', async () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      if (typeof p.dataUrl !== 'string' || !p.dataUrl) return send(res, 400, { err: 'dataUrl required (string)' });
      const b64 = p.dataUrl.replace(/^data:image\/\w+;base64,/, '');
      const prompt = 'This is a wallcovering or fabric SAMPLE label. Identify the BRAND from its logo or wordmark and note the typesetting. Reply ONLY as compact JSON: {"brand":"<manufacturer/brand or empty if unsure>","confidence":<0-1>,"logo":"<short logo/wordmark description>","typeface":"<e.g. serif wordmark / sans caps / script>","code":"<any SKU or model number visible, else empty>"}';
      // engine-pluggable ({engine:...}); macvision keeps the model toggle ({model:.. }/{fast:true} → moondream).
      // TK-12090: prefer exo (the $0 VLM ring) for reasoning/identify calls specifically —
      // NOT a change to the global DEFAULT_ENGINE, which /api/ocr (literal-OCR, exo doesn't
      // do char-level transcription) still resolves independently and unaffected. An explicit
      // client {engine:...} always wins (p.engine short-circuits before this ever applies).
      const engine = resolveEngines(p.engine || (HAS_EXO ? 'exo' : undefined))[0] || DEFAULT_ENGINE;
      const useModel = (engine === 'macvision' || engine === 'exo') ? pickVisionModel(p.fast ? 'fast' : p.model) : GEMINI_VISION_MODEL;
      const r = await visionJSON(engine, b64, prompt, { timeoutMs: p.fast ? 25000 : undefined, model: useModel });
      let out = {}; try { out = JSON.parse(r.response || '{}'); } catch (e) { out = {}; }
      const brand = (out.brand || '').toString().trim();
      // primary: cross-check the VLM's brand guess against the learned lexicon, then fuzzy name.
      let matched = brand ? ((LEXICON.find(v => v.re.test(brand.toUpperCase())) || {}).name || fuzzyVendor(brand)) : null;
      // TIEBREAKER (DTD-A): only if name match was inconclusive, fall back to the stored
      // reference-logo fingerprints (compare the live read + typeface to the 20 validated logos).
      let matchVia = matched ? 'name' : null;
      if (!matched) {
        const fp = fingerprintVendor(brand, out.typeface);
        if (fp) { matched = fp.vendor; matchVia = 'fingerprint'; }
      }
      return send(res, 200, { ok: !r.error, model: useModel, engine, cost_usd: ENGINE_COST_USD[engine] || 0,
        brand: brand || null, confidence: (out.confidence ?? null), logo: out.logo || null,
        typeface: out.typeface || null, code: (out.code || '').toString().toUpperCase().replace(/\s+/g, '') || null,
        vendor: matched, match_via: matchVia, err: r.error || null });
    });
    return;
  }

  // Recognize the actual PATTERN/material in a photo (the swatch itself, not the label)
  // and surface similar items from the WHOLE unified mirror (~169k products). The local
  // VLM reads the pattern's attributes; the local Postgres mirror — whose `tags` are
  // AI-enriched with colors/style/motif/material — is ranked by attribute overlap. $0.
  if (u.pathname === '/api/recognize' && req.method === 'POST') {
    let body = ''; req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
    req.on('end', async () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      if (typeof p.dataUrl !== 'string' || !p.dataUrl) return send(res, 400, { err: 'dataUrl required (string)' });
      const b64 = p.dataUrl.replace(/^data:image\/\w+;base64,/, '');
      const prompt = 'You are looking at a wallcovering or fabric SWATCH — the material itself, NOT a printed label. Describe the PATTERN so it can be matched against a catalog. Reply ONLY as compact JSON: {"description":"<one short sentence>","motif":"<main motif e.g. floral, damask, geometric, grasscloth, stripe, botanical, abstract, ikat, toile>","style":"<e.g. traditional, modern, transitional, scandinavian, art deco, contemporary>","material":"<e.g. grasscloth, non-woven, silk, vinyl, paper, leather>","colors":["<color name>","<color name>"],"background":"<background color name>","scale":"<small | medium | large>","code":"<any SKU or model number printed on it, else empty>"}';
      // TK-12090: prefer exo (the $0 VLM ring) for reasoning/identify calls specifically —
      // NOT a change to the global DEFAULT_ENGINE, which /api/ocr (literal-OCR, exo doesn't
      // do char-level transcription) still resolves independently and unaffected. An explicit
      // client {engine:...} always wins (p.engine short-circuits before this ever applies).
      const engine = resolveEngines(p.engine || (HAS_EXO ? 'exo' : undefined))[0] || DEFAULT_ENGINE;
      const r = await visionJSON(engine, b64, prompt, { timeoutMs: 90000 });   // allow for a cold model load
      let a = {}; try { a = JSON.parse(r.response || '{}'); } catch (e) { a = {}; }
      const terms = similarTerms(a);
      let items = []; try { items = await unifiedSimilar(terms); } catch (e) { items = []; }
      const code = (a.code || '').toString().toUpperCase().replace(/\s+/g, '');
      // VISION MATCH: don't stop at typed attributes — show Gemini the ACTUAL photo + the top
      // candidate IMAGES and let it pick the true pixel match, then float it to the front.
      let visual = null;
      if (items.length && GEMINI_API_KEY && p.visualMatch !== false) {
        try {
          const top = items.filter(x => x.image).slice(0, 5);
          const fetched = (await Promise.all(top.map(x => fetchImageB64(x.image, 2e6))))
            .map((bb, i) => ({ bb, item: top[i] })).filter(x => x.bb);
          if (fetched.length) {
            const vm = await geminiVisualMatch(b64, fetched.map(x => x.bb));
            const cost = 0.0006 * (fetched.length + 1);
            if (vm && vm.best >= 0 && vm.best < fetched.length) {
              const chosen = fetched[vm.best].item;
              items = [chosen, ...items.filter(x => x !== chosen)];
              visual = { matched_sku: chosen.dw_sku || null, matched_mfr: chosen.mfr || null, matched_title: chosen.title || null, matched_image: chosen.image || null, confidence: vm.confidence, via: 'gemini-visual', compared: fetched.length, cost_usd: cost };
            } else { visual = { matched_sku: null, confidence: vm && vm.confidence, via: 'gemini-visual', note: 'no confident visual match', compared: fetched.length, cost_usd: cost }; }
          }
        } catch (e) { visual = { error: e.message }; }
      }
      // provider is only reported when a provider actually SERVED the call — on failure it is
      // null, never the requested engine name (a failed exo call must not read as provider:exo).
      return send(res, 200, { ok: !r.error, model: ((engine === 'macvision' || engine === 'exo') ? (r.model || OLLAMA_VISION_MODEL) : GEMINI_VISION_MODEL), engine, provider: r.error ? null : (r.provider || engine), cost_usd: r.cost_usd,
        recognized: {
          description: a.description || null, motif: a.motif || null, style: a.style || null,
          material: a.material || null, colors: Array.isArray(a.colors) ? a.colors.slice(0, 6) : [],
          background: a.background || null, scale: a.scale || null, code: code || null
        },
        visual, terms, total: items.length, items, err: r.error || null });
    });
    return;
  }

  // /selfcheck — re-runnable health report. Verifies data integrity + AUTO-CLEANS stale
  // photo refs (progress entries pointing at a deleted /photos file = the dead-link class).
  if (u.pathname === '/selfcheck') {
    const checks = [];
    const ck = (name, ok, detail) => checks.push({ name, ok: !!ok, detail });
    ck('server', true, 'up');
    ck('shopify token', !!TOKEN, TOKEN ? 'present' : 'MISSING — creates/photos will fail');
    ck('catalog indexed', CATALOG.length > 0, `${CATALOG.length} live Fentucci`);
    ck('lookup index', INDEX.length > 0, `${INDEX.length} total (incl. ${INDEX.filter(x => x.needs_create).length} new-from-sheet)`);
    ck('sheet GRS loaded', SHEET.length > 0, `${SHEET.length} GRS rows`);
    let queueN = -1; try { queueN = loadJSON(QUEUE_FILE, []).length; } catch (e) {}
    ck('worklist queue', queueN >= 0, `${queueN} need a photo`);
    // stale local-photo refs → auto-clean so no card 404s
    let stale = 0, fixed = 0;
    for (const [sku, e] of Object.entries(progress)) {
      if (e.photo && e.photo.startsWith('/photos/') && !fs.existsSync(path.join(PHOTOS, path.basename(e.photo)))) {
        stale++; if (e.shopify_pushed || e.live || e.created) e.photo = null; else delete progress[sku]; fixed++;
      }
    }
    if (fixed) saveProgress();
    ck('stale photo refs', true, stale ? `cleaned ${fixed} dead thumbnail ref(s)` : 'none');
    const ok = checks.every(c => c.ok);
    const wantsHtml = (req.headers.accept || '').includes('text/html');
    if (!wantsHtml) return send(res, 200, { ok, version: buildLabel(), checks, ts: new Date().toISOString() });
    const rows = checks.map(c => `<tr><td>${c.ok ? '✅' : '❌'}</td><td>${c.name}</td><td class="d">${c.detail || ''}</td></tr>`).join('');
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
    return res.end(`<!doctype html><meta name=viewport content="width=device-width,initial-scale=1"><style>
      body{font:15px -apple-system,sans-serif;background:#0f0e0c;color:#f3efe7;margin:0;padding:20px}
      h1{font-size:18px;color:#c8a24a;letter-spacing:.04em} .big{font-size:22px;font-weight:800;margin:8px 0}
      table{width:100%;border-collapse:collapse;margin-top:10px} td{padding:9px 6px;border-bottom:1px solid #2e2a25;vertical-align:top}
      .d{color:#9a9184;font-size:13px} a{color:#c8a24a}</style>
      <h1>DW SKU Photos — Self-Check</h1>
      <div class="big">${ok ? '✅ All systems go' : '❌ Issues found'} · ${buildLabel()}</div>
      <table>${rows}</table>
      <p class="d">${new Date().toLocaleString()} · <a href="/selfcheck">re-run</a> · <a href="/app">← app</a></p>`);
  }

  // Front screen (TK-12343, Steve 2026-09-26): one-at-a-time swipe theme picker. The capture
  // app itself moved to /app (and /index.html); the picker's "Go to the app" button leads there.
  if (u.pathname === '/') {
    const f = path.join(ROOT, 'public/pick.html');
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, must-revalidate' });
    return res.end(fs.readFileSync(f));
  }
  // Figma theme mockups v1–v25 for the picker. Bounded: fixed dir, basename-only, .html only.
  if (_p.startsWith('/themes/')) {
    const name = _p.slice('/themes/'.length);
    if (name.includes('/') || name.includes('..') || !/^v[0-9]+-[a-z0-9-]+\.html$/.test(name)) return send(res, 404, { err: 'not found' });
    const TDIR = path.join(ROOT, 'public/themes');
    const fp = path.join(TDIR, name);
    if (!fp.startsWith(TDIR + path.sep) || !fs.existsSync(fp)) return send(res, 404, { err: 'not found' });
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
    return res.end(fs.readFileSync(fp));
  }

  if (u.pathname === '/app' || u.pathname === '/index.html') {
    // no-store: the UI evolves fast and is read fresh per request — never let a
    // browser serve a stale cached page (that hid the photo editor after a deploy).
    let html = fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8').replace(/__VER__/g, buildLabel());
    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, must-revalidate' }),
           res.end(html);
  }

  if (u.pathname === '/api/queue' && req.method === 'GET') {
    const q = buildQueue();
    return send(res, 200, { total: q.length, done: q.filter(x => x.done).length, items: q });
  }

  // Recently-updated SKUs (auto-logged on every upload), newest first.
  if (u.pathname === '/api/recents' && req.method === 'GET') {
    const items = Object.values(recents)
      .sort((a, b) => String(b.ts || '').localeCompare(String(a.ts || ''))).slice(0, 40)
      .map(x => ({ ...x, fav: !!favorites[x.dw_sku] }));
    return send(res, 200, { total: items.length, items });
  }
  // Starred SKUs the user updates often, most-recently-starred first.
  if (u.pathname === '/api/favorites' && req.method === 'GET') {
    const items = Object.values(favorites)
      .sort((a, b) => String(b.fav_ts || '').localeCompare(String(a.fav_ts || '')))
      .map(x => ({ ...x, fav: true }));
    return send(res, 200, { total: items.length, items });
  }
  // Toggle a favorite. Body: { dw_sku, on:bool, meta:{title,mfr,price,image,product_id,status} }
  if (u.pathname === '/api/favorite' && req.method === 'POST') {
    let body = ''; req.on('data', c => body += c);
    req.on('end', () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      if (!p.dw_sku) return send(res, 400, { err: 'dw_sku required' });
      if (p.on === false) { delete favorites[p.dw_sku]; }
      else { favorites[p.dw_sku] = cardRecord(p.dw_sku, p.meta, { fav_ts: new Date().toISOString() }); }
      saveFavorites();
      return send(res, 200, { ok: true, fav: p.on !== false, count: Object.keys(favorites).length });
    });
    return;
  }

  // Look up ANY Fentucci (TWIL) product — by DW SKU, model #, or name — to add/replace its photo.
  if (u.pathname === '/api/lookup' && req.method === 'GET') {
    const q = (u.searchParams.get('q') || '').trim().toLowerCase();
    if (!q) return send(res, 200, { items: [], indexed: CATALOG.length });
    const terms = q.split(/\s+/);
    const matches = INDEX.filter(x => {
      const hay = `${x.dw_sku} ${x.mfr} ${x.title} ${(x.collections || []).join(' ')}`.toLowerCase();
      return terms.every(t => hay.includes(t));
    }).slice(0, 80);
    return send(res, 200, { total: matches.length, items: matches, indexed: INDEX.length });
  }

  // Discontinued/deleted SKU resolver → { found, sku, title, status, discontinued, successor }.
  // Dash/punct-insensitive; catches base-of-SAMPLE (CHC-216830 == CHC216830 == CHC-216830-SAMPLE).
  // The scanner UI falls back to this when the main lookup returns 0 active hits, and renders a RED
  // card pointing at the live SUCCESSOR (the product the user should photograph/reorder). $0 local psql.
  if (u.pathname === '/api/discontinued' && req.method === 'GET') {
    const q = (u.searchParams.get('q') || u.searchParams.get('sku') || '').trim();
    if (!q) return send(res, 200, { found: false });
    resolveDiscontinued(q)
      .then(r => send(res, 200, r || { found: false, sku: q }))
      .catch(e => send(res, 200, { found: false, err: e.message }));
    return;
  }

  // All sheet GRS items that don't exist in Shopify yet — the "create on photo" worklist.
  if (u.pathname === '/api/new' && req.method === 'GET') {
    const items = INDEX.filter(x => x.needs_create);
    return send(res, 200, { total: items.length, items });
  }

  // ALL TWIL/Fentucci items (every product on the line, not just the photo worklist).
  if (u.pathname === '/api/twil' && req.method === 'GET') {
    const items = INDEX.map(x => ({ ...x, keep_images: !x.needs_create }))
      .sort((a, b) => (a.title || '').localeCompare(b.title || ''));
    return send(res, 200, { total: items.length, items });
  }

  if (u.pathname === '/api/reindex' && req.method === 'POST') {
    buildCatalog().then(() => {}).catch(() => {});
    return send(res, 200, { ok: true, indexing: true, current: CATALOG.length });
  }

  // Look up ANY Shopify product across the WHOLE store — live search, no pre-index.
  // Hits Shopify GraphQL on demand so it covers all ~160k SKUs without a heavy boot.
  if (u.pathname === '/api/shopify-search' && req.method === 'GET') {
    const q = (u.searchParams.get('q') || '').trim();
    if (!q) return send(res, 200, { items: [], scope: 'shopify' });
    if (!TOKEN) return send(res, 200, { items: [], scope: 'shopify', err: 'no Shopify token' });
    shopifySearch(q).then(items => send(res, 200, { total: items.length, items, scope: 'shopify' }))
      .catch(e => send(res, 200, { items: [], scope: 'shopify', err: e.message }));
    return;
  }

  // Import AS MUCH INFO AS POSSIBLE from a label photo — a rich Gemini extraction of every field.
  if (u.pathname === '/api/extract' && req.method === 'POST') {
    let body = ''; req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
    req.on('end', async () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      if (typeof p.dataUrl !== 'string' || !p.dataUrl) return send(res, 400, { err: 'dataUrl required (string)' });
      const b64 = p.dataUrl.replace(/^data:image\/\w+;base64,/, '');
      const prompt = 'This image is a product SAMPLE label OR a printed SPEC SHEET for an interior product '
        + '(wallcovering, fabric, trim, drapery, rug, or similar) from ANY manufacturer. Extract EVERY spec you can find. '
        + 'Reply ONLY as compact JSON with EXACTLY these keys (use "" for any field not present, '
        + 'preserve SKU/model codes and numbers EXACTLY, do not invent): '
        + '{"vendor":"<manufacturer/brand>",'
        + '"material":"<product type: Wallcovering / Fabric / Trim / Drapery / Rug / etc.>",'
        + '"mfr_sku":"<manufacturer number / item code>",'
        + '"pattern_name":"<pattern or design name>",'
        + '"color":"<colorway / color name>",'
        + '"collection":"<collection or book name>",'
        + '"width":"<material width, e.g. 27 in / 52 cm>",'
        + '"roll_length":"<length of one roll or bolt, e.g. 5 yds / 11 yds / 8 m>",'
        + '"repeat":"<pattern repeat, e.g. 25.2 in / V 64 cm>",'
        + '"pattern_match":"<match type: straight / half-drop / random / free / reverse>",'
        + '"substrate":"<substrate or contents / composition, e.g. non-woven, grasscloth, vinyl on paper, 100% cotton>",'
        + '"how_sold":"<unit of sale: single roll / double roll / per yard / per panel / per meter>",'
        + '"price":"<numeric price if shown>",'
        + '"price_code":"<any printed price code / tier letter or code>"}.';
      const r = await geminiVision(b64, prompt, 30000);
      let a = {}; try { a = JSON.parse(r.response || '{}'); } catch (e) { a = {}; }
      // map the read vendor to a known catalog vendor (fuzzy)
      const vendorMatch = a.vendor ? (fuzzyVendor(a.vendor) || a.vendor) : null;
      // vendor_registered: does the read vendor resolve to a vendor_registry row (real DW# series)?
      // vendor_matched falls back to the raw label text, so callers that save WITHOUT a human
      // confirming (auto-save) must check this, or an unmatched vendor mints a PROV- sku.
      const reg = vendorMatch ? await getVendorsRegistry().catch(() => []) : [];
      const vreg = vendorMatch ? findVendorReg(reg, vendorMatch) : null;
      send(res, 200, { ok: !r.error, fields: a, vendor_matched: vreg ? vreg.vendor : vendorMatch, vendor_registered: !!vreg,
        cost_usd: 0.0006, err: r.error || null });
    });
    return;
  }

  // Vendor + vid list for the "add new item" dropdown (distinct real vendors from the catalog).
  if (u.pathname === '/api/vendors' && req.method === 'GET') {
    getVendors().then(list => send(res, 200, { ok: true, vendors: list })).catch(e => send(res, 200, { ok: false, err: e.message, vendors: [] }));
    return;
  }

  // VENDOR-FIRST dropdown source — the canonical vendor_registry (drives the DW# mint + Shopify
  // vendor + FileMaker vid). This is what the XL "pick vendor first" menu loads.
  if (u.pathname === '/api/vendors-registry' && req.method === 'GET') {
    getVendorsRegistry().then(list => send(res, 200, { ok: true, count: list.length, vendors: list }))
      .catch(e => send(res, 200, { ok: false, err: e.message, vendors: [] }));
    return;
  }

  // Add a NEW item → Shopify DRAFT + dw_unified staging. dryRun (default) PREVIEWS; commit:true writes.
  // Never auto-published (going live is a separate gated step); dedups on mfr#.
  if (u.pathname === '/api/create-item' && req.method === 'POST') {
    let body = '', _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 25 * 1024 * 1024) _big = true; });
    req.on('end', async () => {
      if (_big) return send(res, 413, { ok: false, err: 'body too large' });
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      if (!TOKEN) return send(res, 200, { ok: false, err: 'no Shopify token' });
      const strip = s => s ? s.replace(/^data:image\/\w+;base64,/, '') : null;
      // up to 3 photos (front pattern / back label / detail); front is the primary Shopify image.
      const rawPhotos64 = Array.isArray(p.photos) && p.photos.length ? p.photos.map(strip).filter(Boolean)
        : (p.dataUrl ? [strip(p.dataUrl)].filter(Boolean) : []);
      const photos64 = await Promise.all(rawPhotos64.map(normB64));   // raw camera photos → clean JPEG for Shopify
      p._photos64 = photos64;
      const b64 = photos64[0] || null;
      try {
        const out = TEST_MODE ? testStubCreate(p, p.commit !== true) : await createNewItem(p, b64, p.commit !== true);
        if (p.commit === true && out) {
          // TK-12228: persist EVERY committed photo (front + back + extras) with its side, even when the
          // create failed — so nothing captured is ever lost/undisplayable — and return the served URLs
          // so the result view shows the photos the server actually stored.
          const sku = out.dw_sku || (out.preview && out.preview.dw_sku) || p.mfr;
          const vend = (out.preview && out.preview.vendor) || out.vendor || p.vendor;
          const list = p._photos64 && p._photos64.length ? p._photos64 : (b64 ? [b64] : []);
          const sides = sidesFor(list.length, p.front_present, p.back_present);
          const photos = [];
          list.forEach((ph, i) => {
            const sv = savePhotoB64(ph, sku, sides[i], i); if (!sv) return;
            archiveVendorPhoto(sv.file, { vendor: vend, dw_sku: sku, source: 'create-item' });
            photos.push({ side: sides[i], url: sv.url });
          });
          const rec = recordCapture({ source: 'create-item', dw_sku: sku || null, vendor: vend || null, mfr: p.mfr || null,
            product_id: out.product_id || null, title: out.title || null, ok: !!out.ok, err: out.ok ? null : (out.err || null), photos });
          out.captured = { id: rec && rec.id, photos };
        }
        send(res, 200, out);
      }
      catch (e) { send(res, 500, { ok: false, err: e.message }); }
    });
    return;
  }

  // Resolve an EXISTING Shopify product to update (used by the "Update SKU" media flow):
  // match the scanned mfr# (or dw_sku) against the in-RAM catalog → return its product_id.
  if (u.pathname === '/api/resolve-product' && req.method === 'GET') {
    const mfr = (u.searchParams.get('mfr') || '').trim();
    const sku = (u.searchParams.get('sku') || '').trim();
    const q = mfr || sku;
    const done = hit => send(res, 200, { ok: !!hit, found: !!hit, product_id: hit && hit.product_id, dw_sku: hit && hit.dw_sku, title: hit && hit.title, image: hit && hit.image, status: hit && hit.status });
    // 1) fast in-RAM catalog (the work queue)
    let hit = null;
    if (mfr) { const n = nmfr(mfr); hit = CATALOG.find(x => x.mfr && nmfr(x.mfr) === n); }
    if (!hit && sku) { const s = sku.toUpperCase(); hit = CATALOG.find(x => (x.dw_sku || '').toUpperCase() === s); }
    if (hit || !q) return done(hit);
    // 2) fall back to a LIVE Shopify search (covers the whole ~169k store, not just the queue)
    shopifySearch(q).then(items => {
      const n = nmfr(q), s = q.toUpperCase();
      // EXACT match only — never fall back to items[0]. A wrong bind here would push media
      // LIVE to the wrong customer product (Update mode writes immediately, no approval step).
      const best = items.find(x => x.mfr && nmfr(x.mfr) === n)
        || items.find(x => (x.dw_sku || '').toUpperCase() === s) || null;
      done(best);
    }).catch(() => done(null));
    return;
  }

  // "BEST ID": identify a scanned code against the whole unified catalog (crossref 267k +
  // images) BEFORE Shopify/FileMaker. Returns the canonical identity + FileMaker search keys.
  if (u.pathname === '/api/identify-unified' && req.method === 'GET') {
    const code = (u.searchParams.get('code') || '').trim();
    if (!code) return send(res, 400, { err: 'code required' });
    identifyUnified(code).then(id => send(res, 200, { ok: !!id, found: !!id, identity: id }))
      .catch(e => send(res, 200, { ok: false, found: false, err: e.message }));
    return;
  }

  // Multi-photo identify: fuse ALL clues — BACK label (OCR code → exact crossref) + FRONT pattern
  // (CLIP visual search over the catalog). Body: { back, front } dataUrls (either optional).
  if (u.pathname === '/api/identify-multi' && req.method === 'POST') {
    let body = ''; req.on('data', c => { body += c; if (body.length > 30 * 1024 * 1024) req.destroy(); });
    req.on('end', async () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      const strip = s => (s || '').replace(/^data:image\/\w+;base64,/, '');
      try {
        // BACK → OCR the code → resolve to a canonical identity (exact)
        let code = null, codeId = null, backOcr = null;
        if (p.back) {
          const buf = await normalizeImage(Buffer.from(strip(p.back), 'base64'));   // raw camera photo → JPEG ≤1600px
          backOcr = await backOcrFused(buf).catch(() => null);   // GCV+Gemini fusion (literal codes + layout)
          const cands = [backOcr && backOcr.barcode, backOcr && backOcr.top, ...((backOcr && backOcr.candidates) || [])].filter(Boolean);
          for (const c of cands) { codeId = await identifyUnified(c).catch(() => null); if (codeId) { code = c; break; } }
        }
        // QR clue (client-decoded): a raw code OR a URL. We parse code tokens out and resolve them —
        // "open silent": we read the QR's payload for identity, we never navigate to it.
        let qrUsed = false;
        if (!codeId && p.qr) {
          // pull SKU-shaped tokens (must contain a digit) so URL/domain words (SHOP, EXAMPLE, COM) don't false-match
          const toks = (String(p.qr).toUpperCase().match(/[A-Z0-9][A-Z0-9-]{3,15}/g) || []).filter(t => /\d/.test(t) && !/^\d{1,3}$/.test(t));
          for (const t of toks) { codeId = await identifyUnified(t).catch(() => null); if (codeId) { code = t; qrUsed = true; break; } }
        }
        const backVendor = (backOcr && backOcr.vendor) || null;   // vendor name off the full back label
        // FRONT → CLIP visual search. MULTI-PHOTO FUSION: real handheld photos are noisy (28% single-shot
        // top-1), so if several views of the same sample are sent, fuse them — a product matching across
        // multiple views is corroborated and outranks a spurious single-view match.
        const rawFronts = (Array.isArray(p.fronts) && p.fronts.length ? p.fronts : (p.front ? [p.front] : [])).slice(0, 5).map(strip).filter(Boolean);
        const fronts = await Promise.all(rawFronts.map(async b => { try { return (await normalizeImage(Buffer.from(b, 'base64'))).toString('base64'); } catch (e) { return b; } }));
        let visual = [];
        if (fronts.length === 1) {
          visual = await visualSearch(fronts[0], 8);
        } else if (fronts.length > 1) {
          const lists = await Promise.all(fronts.map(f => visualSearch(f, 8).catch(() => [])));
          const agg = new Map();
          for (const list of lists) for (const it of list) {
            const key = it.dw_sku || it.vc_id || it.mfr_sku || it.image || JSON.stringify(it);
            const e = agg.get(key) || { it, scores: [] }; e.scores.push(it.score || 0); agg.set(key, e);
          }
          const nViews = lists.length;
          visual = Array.from(agg.values()).map(e => {
            const mean = e.scores.reduce((a, b) => a + b, 0) / e.scores.length;
            const bonus = 0.05 * ((e.scores.length - 1) / Math.max(1, nViews - 1));   // multi-view corroboration
            return Object.assign({}, e.it, { score: Math.min(1, mean + bonus), views: e.scores.length });
          }).sort((a, b) => b.score - a.score).slice(0, 8);
        }
        // vendor clue: with no exact code, prefer visual matches from the vendor printed on the label
        if (!codeId && backVendor && visual.length) {
          const vlow = backVendor.toLowerCase().split(/\s+/)[0];
          visual = visual.slice().sort((a, b) => (String(b.vendor || '').toLowerCase().includes(vlow) ? 1 : 0) - (String(a.vendor || '').toLowerCase().includes(vlow) ? 1 : 0));
        }
        // FUSE the clues
        let primary = null, confidence = 'low', source = null, corroborated = false;
        if (codeId) {
          primary = { dw_sku: codeId.internal_sku, mfr_sku: codeId.mfr_sku, mfr_code: codeId.mfr_code, vendor: codeId.vendor, pattern: codeId.pattern, image: codeId.image };
          source = qrUsed ? 'qr' : 'code'; confidence = 'high';
          if (!primary.vendor && backVendor) primary.vendor = backVendor;
          corroborated = visual.some(v => (v.dw_sku && codeId.internal_sku && String(v.dw_sku).toUpperCase() === String(codeId.internal_sku).toUpperCase()) || (v.mfr_sku && codeId.mfr_sku && v.mfr_sku === codeId.mfr_sku));
          if (corroborated) confidence = 'very-high';
        } else if (visual.length) {
          const v0 = visual[0];
          primary = { dw_sku: v0.dw_sku, mfr_sku: v0.mfr_sku, vendor: v0.vendor || backVendor, pattern: v0.pattern, image: v0.image };
          source = 'visual'; confidence = v0.score >= 0.92 ? 'high' : v0.score >= 0.85 ? 'medium' : 'low';
        }
        // SAVE EVERY SCAN — raw photo(s) + what OCR read + engines + resolved identity (found or not).
        saveScan({ back: p.back, front: p.front || (fronts[0] ? 'data:image/jpeg;base64,' + fronts[0] : null) }, {
          found: !!primary, source, confidence, corroborated, code,
          engines: backOcr && backOcr.engines, cost_usd: backOcr && backOcr.cost_usd,
          candidates: backOcr && backOcr.candidates, vendor: backVendor,
          resolved: primary && { dw_sku: primary.dw_sku, mfr_sku: primary.mfr_sku, mfr_code: primary.mfr_code, vendor: primary.vendor, pattern: primary.pattern } });
        send(res, 200, { ok: true, found: !!primary, primary, confidence, source, corroborated, qr_used: qrUsed, back_vendor: backVendor,
          code, code_identity: codeId, visual,
          back_read: backOcr && { top: backOcr.top, candidates: backOcr.candidates, vendor: backOcr.vendor, fields: backOcr.fields, engines: backOcr.engines, cost_usd: backOcr.cost_usd } });
      } catch (e) { send(res, 500, { err: e.message }); }
    });
    return;
  }

  // Sample-request lookup: BEST-ID the scanned item against the unified catalog, then find the
  // most-recent OPEN sample request for it in FileMaker (client-ordered filled + vendor-ordered
  // filled + WP-sample-sent EMPTY). Returns identity + client name + dates for the print popup.
  if (u.pathname === '/api/sample-request' && (req.method === 'GET' || req.method === 'POST')) {
    const run = (p) => {
      const code = (p.code || p.sku || '').toString().trim();
      const mfr = (p.mfr || '').toString().trim();
      if (!code && !mfr) return send(res, 400, { err: 'code required' });
      if (!FM_ENABLED()) return send(res, 200, { ok: false, found: false, err: 'FileMaker not configured' });
      (async () => {
        // 1) identify-first against the unified catalog → canonical house/mfr codes (= FileMaker keys)
        let id = await identifyUnified(code).catch(() => null);
        if (!id && mfr) id = await identifyUnified(mfr).catch(() => null);
        const internal = id && id.internal_sku;
        const mfrCode = (id && id.mfr_code) || mfr || code;
        // 2) FileMaker: OR(combo sku exact, Mfr Pattern begins-with), each AND'd with the 3 date conditions
        const dc = { 'today for client': '*', 'Date Sample Request printed for vendor': '*', 'Date WP Sample Sent': '=' };
        const query = [];
        if (internal) query.push(Object.assign({ 'combo sku': '==' + internal }, dc));
        if (mfrCode) query.push(Object.assign({ 'Mfr Pattern': mfrCode + '*' }, dc));
        if (!query.length) return send(res, 200, { ok: true, found: false, identity: id });
        let r;
        try { r = await FM.fmFind(FM_DB, FM_LAYOUT, query, { limit: 1, sort: [{ fieldName: 'today for client', sortOrder: 'descend' }] }); }
        catch (e) { return send(res, 200, { ok: false, found: false, identity: id, err: e.message }); }
        const rec = r.records[0];
        if (!rec) return send(res, 200, { ok: true, found: false, identity: id });
        const f = rec.fieldData;
        send(res, 200, { ok: true, found: true, recordId: rec.recordId, identity: id,
          item: { combo_sku: f['combo sku'] || null, mfr_pattern: f['Mfr Pattern'] || null, series: f['Series'] || null, vid: f['vid'] || null },
          client: { name: f['company for client fileS'] || null },
          dates: { client_ordered: f['today for client'] || null, vendor_ordered: f['Date Sample Request printed for vendor'] || null, wp_sent: f['Date WP Sample Sent'] || null } });
      })();
    };
    if (req.method === 'GET') return run(Object.fromEntries(u.searchParams));
    let body = ''; req.on('data', c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
    req.on('end', () => { let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); } run(p); });
    return;
  }

  // INCOMING SAMPLES: what Check Sample In focuses on — samples ordered from the vendor but not yet
  // sent to the client (vendor-order date filled, WP-sample-sent empty = still in-flight / arriving).
  // Enriched with each sample's image + spec from the unified catalog so we know what's coming in.
  if (u.pathname === '/api/incoming-samples' && req.method === 'GET') {
    if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured', samples: [] });
    if (Date.now() - _incomingCache.at > 8 * 60 * 1000) refreshIncoming();   // stale → refresh in background
    return send(res, 200, { ok: true, count: _incomingCache.samples.length, samples: _incomingCache.samples,
      cached_at: _incomingCache.at || null, loading: _incomingCache.at === 0 });
  }

  // Check a physically-arrived sample IN: stamp `Date WP Sample Sent` = today on its record. Per Steve's
  // workflow this field IS the received-stamp — an empty value means "still coming in" (exactly what the
  // incoming query filters on), so stamping it records the arrival date AND drops the record out of the
  // incoming queue automatically.
  // NOTE (shared writer): `/api/print-sticker` below ALSO stamps this same `Date WP Sample Sent` field
  // (its "sample sent to client" step). Both flows intentionally CLOSE the open-sample request by filling
  // this one field, so they can't be distinguished afterward (received-in vs sent-out). That's by-design
  // per Steve's model; if the two ever need separate audit trails, add a distinct receive-date field.
  if (u.pathname === '/api/mark-received' && req.method === 'POST') {
    if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured' });
    let body = ''; req.on('data', c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
    req.on('end', async () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { ok: false, err: 'bad json' }); }
      const recordId = (p.recordId || '').toString().trim();
      if (!recordId) return send(res, 400, { ok: false, err: 'recordId required' });
      const today = todayMDY();
      try { await FM.fmSet(FM_DB, FM_LAYOUT, recordId, { 'Date WP Sample Sent': today }); }
      catch (e) { return send(res, 200, { ok: false, err: e.message }); }
      // drop it from the cache immediately so the queue reflects the check-in without waiting for a refresh
      _incomingCache.samples = _incomingCache.samples.filter(s => s.recordId !== recordId);
      send(res, 200, { ok: true, recordId, received: today });
    });
    return;
  }

  // Full product record from the WALLPAPER FileMaker file, keyed by the scanned SKU. WALLPAPER2 is one
  // mega-table (product master + sample-request rows share a combo sku), so a lookup returns several
  // records — most spec fields blank on the sample rows. We COALESCE the non-empty value per field across
  // all matches so the master record's specs surface. 'Basic List of Fields' has the spec fields + NO
  // portals (so it can't stall like the sample-request layout does).
  if (u.pathname === '/api/wallpaper-details' && req.method === 'GET') {
    if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured' });
    (async () => {
      const sku = (u.searchParams.get('sku') || '').trim();     // house combo sku (DWxx…)
      const code = (u.searchParams.get('code') || '').trim();   // mfr pattern (vendor code)
      if (!sku && !code) return send(res, 400, { ok: false, err: 'sku or code required' });
      const LAYOUT2 = process.env.FM_WP_LAYOUT || 'Basic List of Fields';
      // fields to surface: [clean key, FileMaker field name]
      const MAP = [['mfr_pattern','Mfr Pattern'],['js_pattern','JS Pattern'],['series','Series'],
        ['width','Width'],['repeat','Repeat'],['retail','Retail Price'],['net','Net Price'],['cost','Cost'],
        ['supplier','Supplier'],['roll_size','HP Roll Size'],['minimum','Minimum'],['border','Border'],
        ['five_ten','Five Ten Code'],['line','Line'],['internal_desc','Internal Description'],['combo_sku','combo sku']];
      // FileMaker stores combo sku DASHLESS (CHC217203) but scans read it dashed (CHC-217203). So try
      // BOTH the raw AND the alphanumeric-stripped form, on BOTH the combo-sku and Mfr-Pattern fields,
      // in one OR-query — auto-recovering the dash mismatch. (`==` = exact-whole-field match.)
      const dedupe = a => [...new Set(a.filter(Boolean))];
      const forms = dedupe([sku, sku.replace(/[^A-Za-z0-9]/g, ''), code, code.replace(/[^A-Za-z0-9]/g, '')]);
      const q = []; for (const v of forms) { q.push({ 'combo sku': '==' + v }); q.push({ 'Mfr Pattern': '==' + v }); }
      let recs = [];
      try { const r = await FM.fmFind(FM_DB, LAYOUT2, q, { limit: 20, portal: [] }); recs = r.records || []; }
      catch (e) { return send(res, 200, { ok: false, err: e.message }); }
      if (!recs.length) return send(res, 200, { ok: true, found: false, details: null, matched: 0 });
      const details = {};
      for (const [key, fld] of MAP) {                           // coalesce: first non-empty value across matches
        for (const rec of recs) { const v = (rec.fieldData[fld] || '').toString().trim(); if (v && v !== '=') { details[key] = v; break; } }
      }
      send(res, 200, { ok: true, found: true, details, matched: recs.length });
    })();
    return;
  }

  // Partial / fuzzy lookup in the WALLPAPER file — "we may miss the prefix". Substring-matches the query
  // against combo sku, Mfr Pattern, JS Pattern AND Internal Description (FileMaker `*q*` wildcards, OR
  // across fields). Tolerates a mis-scanned trailing char by progressively trimming from the right
  // (so "217500" still finds "CHC-21750" on the shared "21750"). Results grouped+coalesced per SKU.
  if (u.pathname === '/api/wallpaper-search' && req.method === 'GET') {
    if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured' });
    (async () => {
      const raw = (u.searchParams.get('q') || '').trim();
      const alnum = raw.replace(/[^A-Za-z0-9]/g, '');
      if (alnum.length < 3) return send(res, 200, { ok: true, candidates: [], note: 'need ≥3 chars' });
      const LAYOUT2 = process.env.FM_WP_LAYOUT || 'Basic List of Fields';
      const MAP = [['mfr_pattern','Mfr Pattern'],['js_pattern','JS Pattern'],['series','Series'],
        ['width','Width'],['repeat','Repeat'],['retail','Retail Price'],['net','Net Price'],['cost','Cost'],
        ['supplier','Supplier'],['roll_size','HP Roll Size'],['minimum','Minimum'],['border','Border'],
        ['five_ten','Five Ten Code'],['line','Line'],['internal_desc','Internal Description'],['combo_sku','combo sku']];
      const FIELDS = ['combo sku', 'Mfr Pattern', 'JS Pattern', 'Internal Description'];
      // token list: full alnum, then trimmed 1-char-at-a-time (OCR dropped/added a trailing digit)
      const tokens = []; for (let t = alnum; t.length >= 4 && tokens.length < 3; t = t.slice(0, -1)) tokens.push(t);
      let recs = [];
      for (const tok of tokens) {
        const q = FIELDS.map(f => ({ [f]: '*' + tok + '*' }));    // array = OR across the 4 fields
        try { const r = await FM.fmFind(FM_DB, LAYOUT2, q, { limit: 40, portal: [] }); recs = r.records || []; }
        catch (e) { return send(res, 200, { ok: false, err: e.message }); }
        if (recs.length) break;                                    // stop at the first token that hits
      }
      // group + coalesce per SKU (the mega-table has several rows per SKU; specs live on the master row)
      const groups = new Map();
      for (const rec of recs) { const f = rec.fieldData;
        const key = (f['combo sku'] || f['Mfr Pattern'] || ('r' + rec.recordId)).toString();
        let g = groups.get(key); if (!g) { g = {}; groups.set(key, g); }
        for (const [k, fld] of MAP) { if (!g[k]) { const v = (f[fld] || '').toString().trim(); if (v && v !== '=') g[k] = v; } } }
      send(res, 200, { ok: true, candidates: [...groups.values()].slice(0, 25), matched: groups.size, query: alnum });
    })();
    return;
  }

  // Review the saved-scan corpus (newest first): raw photo ⇄ what OCR read ⇄ resolved SKU. Powers the
  // /scans review page so mis-reads are visible and the app can be improved from real data.
  if (u.pathname === '/api/scans' && req.method === 'GET') {
    const limit = Math.min(300, Math.max(1, parseInt(u.searchParams.get('limit') || '60', 10) || 60));
    const only = u.searchParams.get('filter');   // 'unresolved' → only scans that didn't identify
    let lines = [];
    try { lines = fs.readFileSync(SCAN_LOG, 'utf8').split('\n').filter(Boolean); } catch (e) {}
    const total = lines.length, resolved = lines.reduce((n, l) => n + (/"found":true/.test(l) ? 1 : 0), 0);
    let recs = lines.slice(-Math.min(lines.length, limit * 3)).reverse()
      .map(l => { try { return JSON.parse(l); } catch (e) { return null; } }).filter(Boolean);
    if (only === 'unresolved') recs = recs.filter(r => !r.found);
    recs = recs.slice(0, limit);
    return send(res, 200, { ok: true, total, resolved, unresolved: total - resolved, count: recs.length, scans: recs });
  }

  // Scan-review page: browse the saved corpus (photo ⇄ OCR read ⇄ resolved SKU) to spot mis-reads.
  if (u.pathname === '/scans' && req.method === 'GET') {
    const html = `<!doctype html><meta charset=utf8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Scan review · DW Photo</title><style>
body{margin:0;background:#141310;color:#eadfc6;font:14px/1.4 -apple-system,system-ui,sans-serif}
header{position:sticky;top:0;background:#1a1813;border-bottom:1px solid #2a2721;padding:12px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
header b{font-size:16px}.stat{font-size:12px;color:#9a917d}.stat b{color:#eadfc6}
button{padding:6px 12px;font-size:13px;border:1px solid #2a2721;border-radius:8px;background:#0f0e0b;color:#c8bfa8;cursor:pointer}button.on{border-color:#2bd06a;color:#2bd06a}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;padding:16px}
.card{border:1px solid #2a2721;border-radius:12px;overflow:hidden;background:#0f0e0b}
.imgs{display:flex;gap:2px;background:#000}.imgs img{width:100%;height:150px;object-fit:cover;flex:1}
.body{padding:9px 11px}.read{font:12px ui-monospace,Menlo,monospace;color:#c8bfa8;word-break:break-word}
.eng{font-size:10px;color:#8f887a}.res{margin-top:6px;font-size:12px}.ok{color:#2bd06a}.no{color:#e0894a}
.when{font-size:10px;color:#6f6857;margin-top:5px}
</style>
<header><b>📸 Scan review</b><span class="stat" id=stat>…</span>
<button id=all class=on onclick="setF('')">All</button><button id=un onclick="setF('unresolved')">Unresolved only</button>
<button onclick="load()">↻ Refresh</button></header>
<div class=grid id=grid></div>
<script>
let F='';
function setF(f){F=f;document.getElementById('all').className=f?'':'on';document.getElementById('un').className=f?'on':'';load();}
async function load(){
  const d=await(await fetch('/api/scans?limit=120'+(F?'&filter='+F:''))).json();
  document.getElementById('stat').innerHTML='<b>'+d.total+'</b> scans · <b>'+d.resolved+'</b> resolved · <b>'+d.unresolved+'</b> unresolved';
  document.getElementById('grid').innerHTML=(d.scans||[]).map(s=>{
    const imgs=Object.values(s.images||{}).map(u=>'<img src="'+u+'" loading=lazy>').join('')||'<div style="height:150px;display:flex;align-items:center;justify-content:center;color:#5a544a">no image</div>';
    const cands=(s.candidates||[]).slice(0,6).join(' · ')||'—';
    const eng=(s.engines||[]).join('+');
    const res=s.found?('<span class=ok>✓ '+[(s.resolved&&(s.resolved.mfr_code||s.resolved.mfr_sku)),(s.resolved&&s.resolved.dw_sku)].filter(Boolean).join(' · ')+'</span> <span class=eng>('+s.source+'/'+s.confidence+')</span>'):'<span class=no>✗ not identified</span>';
    const when=new Date(s.at).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
    return '<div class=card><div class=imgs>'+imgs+'</div><div class=body><div class=read>📄 '+cands+'</div><div class=eng>'+(eng||'')+(s.cost_usd?' · $'+s.cost_usd:'')+'</div><div class=res>'+res+'</div><div class=when>🕓 '+when+'</div></div></div>';
  }).join('')||'<p style="padding:16px;color:#8f887a">No scans yet — scan a label and it appears here.</p>';
}
load();
</script>`;
    return send(res, 200, html, { 'Content-Type': 'text/html; charset=utf-8' });
  }

  // Print-sticker "send": STAMP the sample as sent (Date WP Sample Sent = today — Steve's "field to
  // fill in") and, if a flag field is configured, ALSO flag the record so the Mac's FileMaker Pro
  // poller runs the physical Zebra print. Write is dryRun unless FM_WRITE=1 (protects the live file).
  if (u.pathname === '/api/print-sticker' && req.method === 'POST') {
    let body = ''; req.on('data', c => { body += c; if (body.length > 16 * 1024) req.destroy(); });
    req.on('end', () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      const recordId = (p.recordId || '').toString().trim();
      const sticker = p.sticker === 'address' ? 'address' : 'sku';
      if (!recordId) return send(res, 400, { err: 'recordId required' });
      if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured' });
      const dryRun = process.env.FM_WRITE !== '1';
      const fieldData = {};
      // Stamp the sent date (US MM/DD/YYYY, matching the file's date format) unless disabled.
      if (process.env.FM_STAMP_SENT !== '0') fieldData[process.env.FM_SENT_FIELD || 'Date WP Sample Sent'] = todayMDY();
      // Optional poller flag for the physical Zebra print (Mac FileMaker Pro side).
      if (FM_PRINT_FLAG) fieldData[FM_PRINT_FLAG] = sticker;
      if (!Object.keys(fieldData).length) return send(res, 200, { ok: false, err: 'nothing to write (FM_STAMP_SENT=0 and no FM_PRINT_FLAG_FIELD)' });
      // dryRun: report the intended write WITHOUT touching FileMaker (a pre-read on records with big
      // portals can stall). Live: direct PATCH via fmSet (no pre-read) so it's fast and can't hang.
      if (dryRun) return send(res, 200, { ok: true, committed: false, dryRun: true, sticker, wrote: fieldData });
      FM.fmSet(FM_DB, FM_LAYOUT, recordId, fieldData)
        .then(() => send(res, 200, { ok: true, committed: true, dryRun: false, sticker, wrote: fieldData }))
        .catch(e => send(res, 200, { ok: false, err: e.message }));
    });
    return;
  }

  // Ask a question about a SKU — local Ollama answers from the product's own Shopify facts. $0.
  if (u.pathname === '/api/ask' && req.method === 'POST') {
    let body = '';
    req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
    req.on('end', async () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      const question = (p.question || '').trim();
      if (!question) return send(res, 400, { err: 'question required' });
      try {
        const ctx = await productContext(p.product_id);
        if (!ctx) return send(res, 200, { answer: "I can't find that product's details." });
        const prompt = `You are a concise assistant for a wallcovering showroom. Answer the question using ONLY the product facts below. If a fact isn't listed, say you don't have that detail — don't guess. One or two short sentences.\n\nPRODUCT FACTS:\n${ctx}\n\nQUESTION: ${question}\nANSWER:`;
        const answer = await ollamaAsk(prompt);
        send(res, 200, { answer: (answer || '').trim() || 'No answer.', model: OLLAMA_MODEL });
      } catch (e) { send(res, 200, { answer: 'Sorry — the local AI is unreachable right now.', err: e.message }); }
    });
    return;
  }

  if (u.pathname.startsWith('/photos/')) {
    // Serve any file under PHOTOS, including the batch/<session>/ subtree, with a strict
    // path-traversal guard (resolved path must stay inside PHOTOS). Backward-compatible with
    // the old basename-only form (a bare /photos/<file>.jpg still resolves).
    let rel; try { rel = decodeURIComponent(u.pathname.slice('/photos/'.length)); } catch (e) { rel = ''; }
    const f = path.join(PHOTOS, rel);
    if (f.startsWith(PHOTOS + path.sep) && fs.existsSync(f) && fs.statSync(f).isFile()) {
      res.writeHead(200, { 'Content-Type': 'image/jpeg' }); return res.end(fs.readFileSync(f));
    }
    return send(res, 404, { err: 'not found' });
  }

  // ── Shared front-end modules (public/js/*.js) — bounded static serve for the extracted
  //    capture-pipeline engine (and any future shared module). Same posture as /marketing/:
  //    basename-only + .js/.mjs allowlist + path-traversal guard. Behind the app basic-auth
  //    (the authed page fetches it as a same-origin subresource, so creds ride along).
  //    no-store so a redeploy of the module takes effect immediately (mirrors the HTML pages).
  if (_p.startsWith('/js/')) {
    const name = _p.slice('/js/'.length);
    if (name.includes('/') || name.includes('..') || !/^[A-Za-z0-9._-]+\.m?js$/.test(name)) {
      return send(res, 404, { err: 'not found' });
    }
    const JDIR = path.join(ROOT, 'public/js');
    const fp = path.join(JDIR, name);
    if (!fp.startsWith(JDIR + path.sep) || !fs.existsSync(fp) || !fs.statSync(fp).isFile()) {
      return send(res, 404, { err: 'not found' });
    }
    res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
    return res.end(fs.readFileSync(fp));
  }

  // ── Kid-Simple redesign review page (TK-12344): static mockups v1–v16 + the Photoshop board.
  //    Same bounded posture as /js/: fixed dir, basename-only (+ one renders/ subdir), extension
  //    allowlist, path-traversal guard, behind the app basic-auth. Mockups only — no API calls.
  if (_p === '/kid-simple') { res.writeHead(301, { Location: '/kid-simple/' }); return res.end(); }
  if (_p.startsWith('/kid-simple/')) {
    let name = _p.slice('/kid-simple/'.length) || 'index.html';
    const m = /^(renders\/)?([A-Za-z0-9._-]+\.(html|png|jpg))$/.exec(name);
    if (!m || name.includes('..')) return send(res, 404, { err: 'not found' });
    const KDIR = path.join(ROOT, 'public/kid-simple');
    const fp = path.join(KDIR, name);
    if (!fp.startsWith(KDIR + path.sep) || !fs.existsSync(fp) || !fs.statSync(fp).isFile()) {
      return send(res, 404, { err: 'not found' });
    }
    const type = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg' }[m[3]];
    res.writeHead(200, { 'Content-Type': type, 'Cache-Control': m[3] === 'html' ? 'no-store' : 'max-age=3600' });
    return res.end(fs.readFileSync(fp));
  }

  // App icons + PWA manifest — make "Add to Home Screen" a real app (clean DW icon, fullscreen).
  if (u.pathname === '/icon-180.png' || u.pathname === '/icon-192.png' || u.pathname === '/icon-512.png' || u.pathname === '/apple-touch-icon.png') {
    const f = path.join(ROOT, 'public', u.pathname === '/apple-touch-icon.png' ? 'icon-180.png' : path.basename(u.pathname));
    if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
    return send(res, 404, { err: 'not found' });
  }
  if (u.pathname === '/manifest.webmanifest') {
    res.writeHead(200, { 'Content-Type': 'application/manifest+json' });
    return res.end(JSON.stringify({
      name: 'DW Photo Capture', short_name: 'DW Photo', start_url: '/', scope: '/', display: 'standalone',
      background_color: '#0f0e0c', theme_color: '#0f0e0c', orientation: 'portrait',
      icons: [
        { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
        { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
        { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
      ]
    }));
  }

  // Photo gallery: list every image on a product (keep/remove/feature from the UI).
  if (u.pathname === '/api/images' && req.method === 'GET') {
    const pid = u.searchParams.get('pid');
    if (!pid) return send(res, 400, { err: 'pid required' });
    (async () => {
      const r = await shopifyReq('GET', `/products/${pid}/images.json`);
      const imgs = ((r.body && r.body.images) || []).map(i => ({ id: i.id, src: i.src, position: i.position }));
      send(res, 200, { images: imgs });
    })();
    return;
  }
  if ((u.pathname === '/api/image/delete' || u.pathname === '/api/image/feature') && req.method === 'POST') {
    let body = ''; req.on('data', c => body += c);
    req.on('end', async () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      if (!p.pid || !p.image_id) return send(res, 400, { err: 'pid + image_id required' });
      if (u.pathname === '/api/image/delete') {
        const r = await shopifyReq('DELETE', `/products/${p.pid}/images/${p.image_id}.json`);
        return send(res, 200, { ok: r.status >= 200 && r.status < 300 });
      }
      const r = await shopifyReq('PUT', `/products/${p.pid}/images/${p.image_id}.json`, { image: { id: p.image_id, position: 1 } });
      return send(res, 200, { ok: r.status >= 200 && r.status < 300 });
    });
    return;
  }

  // Same-origin proxy for a product's CURRENT Shopify image — lets the editor
  // load + re-adjust a done item that has no local photo (e.g. shot on another
  // device, or a pre-existing image), without a cross-origin canvas taint.
  if (u.pathname === '/api/current-image' && req.method === 'GET') {
    const pid = u.searchParams.get('pid');
    if (!pid) return send(res, 400, { err: 'pid required' });
    (async () => {
      const r = await shopifyReq('GET', `/products/${pid}/images.json`);
      const imgs = (r.body && r.body.images) || [];
      const src = imgs.length ? imgs[imgs.length - 1].src : null; // featured/last
      if (!src) return send(res, 404, { err: 'no image' });
      pipeUpstreamImage(src, res, { 'Cache-Control': 'no-store' });
    })();
    return;
  }

  // Generic same-origin image proxy — lets the browser read pattern pixels off a
  // catalog image (Shopify CDN sends no CORS header → a direct <img> taints the
  // canvas). Host-whitelisted so it can't be turned into an open relay. Used by the
  // 🎨 Similar visual re-rank to fingerprint each candidate's actual pattern image.
  if (u.pathname === '/api/imgproxy' && req.method === 'GET') {
    const src = u.searchParams.get('url') || '';
    let su; try { su = new URL(src); } catch (e) { return send(res, 400, { err: 'bad url' }); }
    const OK = /(^|\.)(shopify\.com|shopifycdn\.com|myshopify\.com)$/i;   // cdn.shopify.com ⊂ .shopify.com
    if (su.protocol !== 'https:' || !OK.test(su.hostname)) return send(res, 403, { err: 'host not allowed' });
    pipeUpstreamImage(su.href, res, { 'Cache-Control': 'max-age=86400', 'Access-Control-Allow-Origin': '*' });
    return;
  }

  // OCR a photo of a printed mfr#/SKU → ranked SKU candidates. Engine-pluggable:
  // {engine:"macvision"|"gemini"|"gcv"|"all"} (or ?engine=). 'all' runs every available
  // engine and returns each under by_engine, picking the strongest read as the top-level result.
  if (u.pathname === '/api/ocr' && req.method === 'POST') {
    let body = '';
    req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
    req.on('end', async () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      if (typeof p.dataUrl !== 'string' || !p.dataUrl) return send(res, 400, { err: 'dataUrl required (string)' });
      let buf; try { buf = Buffer.from(p.dataUrl.replace(/^data:image\/\w+;base64,/, ''), 'base64'); }
      catch (e) { return send(res, 400, { err: 'bad dataUrl' }); }
      const engines = resolveEngines(p.engine || u.searchParams.get('engine'));
      if (!engines.length) return send(res, 503, { err: 'no OCR engine available', all: ALL_ENGINES, available: availableEngines() });
      try {
        const results = await Promise.all(engines.map(e => runOcr(e, buf)));
        // Best read = barcode ground-truth > strong lock > most candidates (only among ok reads).
        const rank = r => (r.ok ? 1 : 0) * ((r.barcode ? 1000 : 0) + (r.topStrong ? 100 : 0) + ((r.candidates || []).length));
        const primary = results.length === 1 ? results[0] : results.slice().sort((a, b) => rank(b) - rank(a))[0];
        const out = Object.assign({}, primary, { ok: !!(primary && primary.ok),
          engine_used: primary && primary.engine, engines_run: engines, available: availableEngines() });
        if (results.length > 1) { out.by_engine = {}; results.forEach(r => { out.by_engine[r.engine] = r; }); }
        send(res, 200, out);
      } catch (e) { send(res, 500, { err: 'ocr fail: ' + e.message }); }
    });
    return;
  }

  // Upload a VIDEO to a Shopify product (staged upload → product media).
  if (u.pathname === '/api/video' && req.method === 'POST') {
    // base64 inflates the file ~33%, so a 150MB clip is ~200MB on the wire → cap at 230MB.
    // DON'T req.destroy() (that leaves the client fetch hanging with no response, swallowed by
    // its catch → false-green "0 videos"); drain past the cap and reply 413 cleanly on end.
    let body = '', tooBig = false;
    req.on('data', c => { if (tooBig) return; body += c; if (body.length > 230 * 1024 * 1024) tooBig = true; });
    req.on('end', async () => {
      if (tooBig) return send(res, 413, { ok: false, err: 'video too large (max ~150MB)' });
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      if (!p.product_id || typeof p.dataUrl !== 'string' || !p.dataUrl) return send(res, 400, { err: 'product_id + dataUrl (string) required' });
      const m = /^data:(video\/\w+);base64,/.exec(p.dataUrl);
      const mime = (m && m[1]) || 'video/mp4';
      const buf = Buffer.from(p.dataUrl.replace(/^data:[^,]+,/, ''), 'base64');
      const safe = String(p.dw_sku || 'video').replace(/[^A-Za-z0-9._-]/g, '');
      const r = await shopifyAddVideo(p.product_id, buf, `${safe}.${mime.split('/')[1] || 'mp4'}`, mime);
      return send(res, r.ok ? 200 : 500, { ok: r.ok, processing: r.processing, err: r.err, size: buf.length });
    });
    return;
  }

  // Poll a product's video transcode state (Shopify processes uploaded video async).
  if (u.pathname === '/api/media-status' && req.method === 'GET') {
    const pid = (u.searchParams.get('product_id') || '').trim();
    if (!pid) return send(res, 400, { err: 'product_id required' });
    shopifyMediaStatus(pid).then(s => send(res, 200, Object.assign({ ok: true }, s)))
      .catch(e => send(res, 200, { ok: false, err: e.message }));
    return;
  }

  if (u.pathname === '/api/photo' && req.method === 'POST') {
    let body = '';
    let _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 25 * 1024 * 1024) _big = true; });
    req.on('end', async () => {
      if (_big) return send(res, 413, { ok: false, err: 'body too large' });
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      const { dw_sku, product_id, dataUrl, push, keep_images, meta } = p;
      if (!dw_sku || !dataUrl) return send(res, 400, { err: 'dw_sku + dataUrl required' });
      const b64 = await normB64(dataUrl.replace(/^data:image\/\w+;base64,/, ''));   // raw photo → clean JPEG
      const side = cleanSide(p.side || (meta && meta.side));
      const sv = savePhotoB64(b64, dw_sku, side, 0);
      if (!sv) return send(res, 500, { ok: false, err: 'could not save photo' });
      archiveVendorPhoto(sv.file, { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photo' });
      const entry = progress[dw_sku] || {};
      entry.done = true; entry.skipped = false; entry.photo = sv.url; entry.ts = new Date().toISOString();
      entry.live = false; entry.live_reason = null; entry.created = false;
      const sheet = SHEET_BY_GRS[String(dw_sku).toUpperCase()];
      const existingPid = product_id || (CATALOG.find(x => (x.dw_sku || '').toUpperCase() === String(dw_sku).toUpperCase()) || {}).product_id;
      if (push !== false && !existingPid && sheet) {
        // NEW SKU: create the Shopify product from the sheet's full info + this photo, then go live.
        const c = await createFromSheet(sheet, b64);
        entry.created = c.ok; entry.shopify_pushed = c.ok; entry.push_err = c.ok ? null : c.err;
        entry.live = !!c.live; entry.live_reason = c.live ? null : (c.reason || c.err);
        entry.product_id = c.product_id || null;
        if (c.ok && c.product_id) CATALOG.push({ product_id: c.product_id, title: c.title, status: c.live ? 'ACTIVE' : 'DRAFT', dw_sku, mfr: sheet.mfr || '', price: sheet.price || null, image: entry.photo, done: true });
      } else if (push !== false && existingPid) {
        // EXISTING product: attach/replace the photo, then activate if the gate now passes.
        // keep_images=true (any-Shopify-SKU mode) preserves existing imagery — never
        // wipe good room/lifestyle shots on an arbitrary live product.
        const r = await shopifyAttachImage(existingPid, dw_sku, b64, !!keep_images);
        entry.shopify_pushed = r.ok; entry.push_err = r.ok ? null : r.err;
        entry.product_id = existingPid;
        if (r.ok) { const a = await shopifyActivateIfReady(existingPid); entry.live = !!a.live; entry.live_reason = a.reason || null; }
      }
      progress[dw_sku] = entry; saveProgress();
      // log into recents (most-recently-updated first) so frequent SKUs are one tap away.
      recents[dw_sku] = cardRecord(dw_sku, meta, {
        product_id: entry.product_id || existingPid || (meta && meta.product_id) || null,
        image: entry.photo || (meta && meta.image) || null,
        status: entry.live ? 'ACTIVE' : (meta && meta.status) || '', ts: entry.ts
      });
      saveRecents();
      recordCapture({ source: 'photo', dw_sku, vendor: p.vendor || (meta && meta.vendor) || null, product_id: entry.product_id || existingPid || null,
        title: (meta && meta.title) || null, ok: true, shopify_pushed: !!entry.shopify_pushed, photos: [{ side, url: sv.url }] });
      return send(res, 200, { ok: true, dw_sku, photo: entry.photo, photos: [{ side, url: sv.url }], created: entry.created, shopify_pushed: entry.shopify_pushed,
        push_err: entry.push_err, live: entry.live, live_reason: entry.live_reason });
    });
    return;
  }

  // Batch add MULTIPLE photos to an existing product (append, keep featured + siblings).
  // Body: { dw_sku, product_id, dataUrls:[...], meta }
  if (u.pathname === '/api/photos' && req.method === 'POST') {
    let body = '';
    let _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 60 * 1024 * 1024) _big = true; });
    req.on('end', async () => {
      if (_big) return send(res, 413, { ok: false, err: 'body too large' });
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      const { dw_sku, product_id, dataUrls, meta } = p;
      if (!dw_sku || !product_id || !Array.isArray(dataUrls) || !dataUrls.length)
        return send(res, 400, { err: 'dw_sku + product_id + dataUrls[] required' });
      const urls = dataUrls.slice(0, 12); // cap a single batch
      const sides = Array.isArray(p.sides) ? p.sides.map(cleanSide) : urls.map(() => 'photo');
      let added = 0; const errors = []; let lastPhoto = null; const saved = [];
      for (let i = 0; i < urls.length; i++) {
        const b64 = await normB64(String(urls[i]).replace(/^data:image\/\w+;base64,/, ''));   // raw photo → clean JPEG
        const sv = savePhotoB64(b64, dw_sku, sides[i] || 'photo', i);
        if (sv) { lastPhoto = sv.url; saved.push({ side: sides[i] || 'photo', url: sv.url });
          archiveVendorPhoto(sv.file, { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photos' }); }
        const r = await shopifyAppendImage(product_id, dw_sku, b64, `${Date.now()}-${i}`);
        if (r.ok) added++; else errors.push(r.err);
      }
      const entry = progress[dw_sku] || {};
      entry.done = true; entry.skipped = false; entry.photo = lastPhoto || entry.photo;
      entry.ts = new Date().toISOString(); entry.shopify_pushed = added > 0; entry.product_id = product_id;
      const a = added > 0 ? await shopifyActivateIfReady(product_id) : {};
      entry.live = !!a.live; entry.live_reason = a.reason || null;
      progress[dw_sku] = entry; saveProgress();
      recents[dw_sku] = cardRecord(dw_sku, meta, { product_id, image: entry.photo,
        status: entry.live ? 'ACTIVE' : (meta && meta.status) || '', ts: entry.ts });
      saveRecents();
      recordCapture({ source: 'update', dw_sku, vendor: p.vendor || (meta && meta.vendor) || null, product_id,
        title: (meta && meta.title) || null, ok: added > 0, err: errors[0] || null, photos: saved });
      return send(res, 200, { ok: added > 0, added, total: urls.length, errors, photos: saved, live: entry.live, live_reason: entry.live_reason });
    });
    return;
  }

  if (u.pathname === '/api/skip' && req.method === 'POST') {
    let body = ''; req.on('data', c => body += c);
    req.on('end', () => {
      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
      const e = progress[p.dw_sku] || {}; e.skipped = !!p.skipped; progress[p.dw_sku] = e; saveProgress();
      return send(res, 200, { ok: true });
    });
    return;
  }

  send(res, 404, { err: 'not found' });
};
const server = http.createServer(appHandler);
// HTTPS (self-signed) on PORT+1 so the live camera scanner (getUserMedia needs a secure context) works on the LAN.
try {
  const tls = { key: fs.readFileSync(path.join(ROOT, 'certs/key.pem')), cert: fs.readFileSync(path.join(ROOT, 'certs/cert.pem')) };
  const httpsSrv = https.createServer(tls, appHandler);
  httpsSrv.on('error', (e) => console.log('[https.on error]', (e && e.code) || (e && e.message) || e)); // secondary LAN listener — log, don't crash
  httpsSrv.listen(Number(PORT) + 1, '0.0.0.0', () => console.log(`HTTPS (live-scan) on https://0.0.0.0:${Number(PORT) + 1}`));
} catch (e) { console.log('HTTPS off (no cert):', e.message); }

// ── Sheet GRS catalog (every GRS item on the TWIL spreadsheet, incl. not-yet-created) ──
let SHEET = loadJSON(path.join(DATA, 'sheet_grs.json'), []);
const SHEET_BY_GRS = {};
SHEET.forEach(x => { if (x.grs) SHEET_BY_GRS[x.grs.toUpperCase()] = x; });

// Manufacturer-SKU index: normalized vendor code -> { sku (DW house SKU), title, ... }.
// Lets a scanned vendor code (WDW2310, WHF3846.WT.0) resolve to the DW product that
// stores that code only in a metafield. Built by build_mfr_index.py. Keys are normalized.
const MFR_INDEX = loadJSON(path.join(DATA, 'mfr_index.json'), {});
const mfrNorm = s => String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
// Resolve a scanned/typed code to a DW house SKU via the index. Tolerates DW's messy codes:
// exact normalized hit first, then a dropped-trailing-char (prefix) match either direction.
function mfrToHouseSku(q) {
  const n = mfrNorm(q);
  if (n.length < 4) return null;
  if (MFR_INDEX[n] && MFR_INDEX[n].sku) return MFR_INDEX[n].sku;       // exact (normalized) — common case
  // fuzzy: DW sometimes drops/adds ONE trailing char. Only accept a key within 1 char
  // where the shorter is a prefix of the longer — tight enough to avoid wild mismatches.
  if (n.length >= 6) {
    for (const k of Object.keys(MFR_INDEX)) {
      if (Math.abs(k.length - n.length) <= 1 && k.length >= 6 && MFR_INDEX[k].sku &&
          (k.startsWith(n) || n.startsWith(k))) return MFR_INDEX[k].sku;
    }
  }
  return null;
}

function sheetTitle(s) {
  const name = (s.name || '').trim(), color = (s.color || '').trim();
  // drop a redundant color when the "name" already IS the color (some sheet rows duplicate them)
  const nl = name.toLowerCase(), cl = color.toLowerCase();
  const col = (color && nl !== cl && !nl.includes(cl) && !cl.includes(nl)) ? ' ' + color : '';
  if (name) return `${name}${col} Grasscloth Wallcovering | Fentucci`;
  if (s.material) return `Fentucci ${s.material} Grasscloth Wallcovering${color ? ' — ' + color : ''}`;
  return `Fentucci Grasscloth Wallcovering${color ? ' — ' + color : ''}`;
}
function sheetBody(s) {
  const lead = s.name || (s.material ? `Fentucci ${s.material} grasscloth` : 'This Fentucci grasscloth');
  if (s.desc && /^is /i.test(s.desc.trim())) return `${lead} ${s.desc.trim()}`;
  return s.desc || `${lead} is a natural woven grasscloth wallcovering with handcrafted natural-fiber texture that brings organic warmth to any interior.`;
}
// Create a NEW Shopify product from a sheet GRS row + the captured photo, then go live.
async function createFromSheet(s, b64) {
  try {
    // dedup: never create if the GRS sku OR the mfr# already exists — attach to that product instead
    const dup = CATALOG.find(x => (x.dw_sku || '').toUpperCase() === s.grs.toUpperCase()
      || (s.mfr && nmfr(x.mfr) === nmfr(s.mfr)));
    if (dup && dup.product_id) {
      const r = await shopifyAttachImage(dup.product_id, dup.dw_sku || s.grs, b64);
      const a = r.ok ? await shopifyActivateIfReady(dup.product_id) : {};
      return { ok: r.ok, product_id: dup.product_id, title: dup.title || '', live: !!a.live, reason: a.reason || 'matched existing product', dedup: true };
    }
    const price = s.price && +s.price > 0 ? String(s.price) : null;
    const active = !!price; // gate: has image (this photo) + price + width + desc
    const title = sheetTitle(s);
    const col = s.color || '';
    const tags = ['Grasscloth', 'Natural Wallcovering', 'Fentucci', 'TWIL Naturals', 'display_variant'];
    if (col) tags.push('color:' + col);
    if (!active) tags.push('Needs-Cost');
    const roll = { option1: 'Per Yard', sku: s.grs, inventory_management: 'shopify', inventory_quantity: 2026 }; // GRS grasscloth sells per yard
    if (price) roll.price = price;
    const mf = (ns, key, val) => val ? { namespace: ns, key, value: String(val), type: 'single_line_text_field' } : null;
    const metafields = [
      mf('global', 'width', s.width || '36 Inches'), mf('global', 'length', s.length || '8 Yards'),
      mf('global', 'unit_of_measure', 'Priced Per Yard'), mf('dwc', 'order_unit', 'Yard'), mf('global', 'Content', 'Natural Grasscloth'),
      mf('global', 'Brand', 'Fentucci'), mf('global', 'Collection', 'TWIL Naturals'),
      mf('global', 'Color-Way', col), mf('custom', 'color', col),
      mf('custom', 'manufacturer_sku', s.mfr), mf('dwc', 'manufacturer_sku', s.mfr),
      mf('custom', 'pattern_name', s.name), mf('dwc', 'pattern_name', s.name)
    ].filter(Boolean);
    const payload = { product: {
      title, body_html: sheetBody(s), vendor: 'Fentucci', product_type: 'Wallcovering',
      status: active ? 'active' : 'draft', published_scope: 'global', tags: tags.join(', '),
      options: [{ name: 'Size' }],
      variants: [roll, { option1: 'Sample', sku: s.grs + '-Sample', price: '4.25', inventory_management: 'shopify', inventory_quantity: 2026 }],
      images: [{ attachment: b64, filename: `${s.grs}.jpg` }],
      metafields
    } };
    const cr = await shopifyReq('POST', '/products.json', payload);
    if (cr.status < 200 || cr.status >= 300) return { ok: false, err: `create HTTP ${cr.status}: ${(cr.raw || '').slice(0, 160)}` };
    const pid = cr.body && cr.body.product && cr.body.product.id;
    let live = false;
    if (active && pid) {
      const pubs = await gql('{ publications(first:50){edges{node{id}}} }');
      const ids = ((pubs.data && pubs.data.publications && pubs.data.publications.edges) || []).map(e => ({ publicationId: e.node.id }));
      await gql(`mutation($id:ID!,$in:[PublicationInput!]!){ publishablePublish(id:$id,input:$in){ userErrors{message} } }`,
        { id: `gid://shopify/Product/${pid}`, in: ids });
      live = true;
    }
    return { ok: true, product_id: pid, title, live, reason: active ? null : 'no price in sheet — created as draft' };
  } catch (e) { return { ok: false, err: e.message }; }
}

// Vendor + vid dropdown for the "add new item" form (distinct real vendors from the unified catalog).
let _vendorsCache = { at: 0, list: [] };
function getVendors() {
  return new Promise(resolve => {
    if (Date.now() - _vendorsCache.at < 10 * 60 * 1000 && _vendorsCache.list.length) return resolve(_vendorsCache.list);
    const SQL = `select coalesce(nullif(original_vendor_name,''), vendor_code) vendor, vendor_code vid, count(*) n
      from vendor_catalog where vendor_code is not null and vendor_code<>'' group by 1,2 having count(*)>5 order by n desc limit 300`;
    execFile(PSQL, ['-d', DW_DB, '-tAF', '\t', '-c', SQL], { timeout: 8000, maxBuffer: 4 * 1024 * 1024 }, (err, out) => {
      if (err) return resolve(_vendorsCache.list);
      const list = (out || '').split('\n').filter(Boolean).map(l => { const [vendor, vid, n] = l.split('\t'); return { vendor, vid, n: +n }; });
      _vendorsCache = { at: Date.now(), list }; resolve(list);
    });
  });
}

// ── tiny psql helpers (this app shells to psql; zero pg driver deps) ──────────
// Safe SQL string literal: standard single-quote escaping ('' doubling). With
// standard_conforming_strings on (PG default), no value can terminate its own
// literal, so this is the correct way to embed user input in a psql -c statement.
// Shared by createNewItem's staging insert and the _pgq successor resolver below.
const pgLit = s => "'" + String(s == null ? '' : s).replace(/'/g, "''") + "'";
function pgRows(sql) {
  return new Promise(resolve => {
    execFile(PSQL, ['-d', DW_DB, '-tAF', '\t', '-c', sql], { timeout: 9000, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {
      if (err) return resolve([]);
      resolve((out || '').split('\n').filter(Boolean).map(l => l.split('\t')));
    });
  });
}
// Like pgRows but distinguishes ERROR (returns {ok:false}) from an empty result set (returns {ok:true,rows:[]}).
// The mint MUST know the difference: a FAILED "what's already used" query is unsafe to treat as "nothing used".
function pgQuery(sql) {
  return new Promise(resolve => {
    execFile(PSQL, ['-d', DW_DB, '-v', 'ON_ERROR_STOP=1', '-tAF', '\t', '-c', sql], { timeout: 9000, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {
      if (err) return resolve({ ok: false, rows: [] });
      resolve({ ok: true, rows: (out || '').split('\n').filter(Boolean).map(l => l.split('\t')) });
    });
  });
}

// ── Metafield TYPE map (store definitions) ───────────────────────────────────
// A metafield value MUST be posted with the type its DEFINITION declares, or Shopify 422s the whole
// product create (e.g. custom.material is defined multi_line_text_field on this store — forcing
// single_line rejects the create). Fetch every PRODUCT definition once (cached 10m) → {"ns.key":type}.
// (namespace,key) pairs with NO definition are free-form and safely accept single_line_text_field.
let _mfTypeCache = { at: 0, map: null };
async function getMetafieldTypeMap() {
  if (_mfTypeCache.map && Date.now() - _mfTypeCache.at < 10 * 60 * 1000) return _mfTypeCache.map;
  const map = {};
  try {
    let cursor = null, pages = 0;
    do {
      const r = await gql(`query($c:String){ metafieldDefinitions(first:250, ownerType:PRODUCT, after:$c){
        pageInfo{ hasNextPage endCursor } edges{ node{ namespace key type{ name } } } } }`, { c: cursor });
      const md = r.data && r.data.metafieldDefinitions;
      if (!md) break;
      for (const e of md.edges) map[`${e.node.namespace}.${e.node.key}`] = e.node.type.name;
      cursor = md.pageInfo.hasNextPage ? md.pageInfo.endCursor : null;
    } while (cursor && ++pages < 10);
  } catch (e) { /* fall back to per-key default below */ }
  _mfTypeCache = { at: Date.now(), map };
  return map;
}

// ── VENDOR-FIRST dropdown source: the canonical vendor_registry ──────────────
// Returns one row per real, active vendor with its canonical DW# minting inputs
// (sku_prefix + sku_range_start) and — best-effort — the short FileMaker `vid`
// (KRA/WQ/PJ…) inferred from the dominant vid on that prefix's existing FM masters.
let _vregCache = { at: 0, list: [] };
// Registry lookup for a vendor name as read off a label or typed: exact vid / display / real name
// first, then case- and punctuation-insensitive ("YORK WALLCOVERINGS" -> "York Wallcoverings").
// The loose pass only accepts a SINGLE hit — two normalized matches is ambiguous, so no guess.
function _normVendor(s) { return String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); }
function findVendorReg(registry, vendor, vid) {
  const hit = registry.find(v => (v.vid && vid && v.vid === vid))
    || registry.find(v => v.vendor === vendor)
    || registry.find(v => (v.real_vendor || '') === vendor);
  if (hit) return hit;
  const n = _normVendor(vendor); if (n.length < 3) return null;
  const loose = registry.filter(v => _normVendor(v.vendor) === n || _normVendor(v.real_vendor) === n);
  return loose.length === 1 ? loose[0] : null;
}

async function getVendorsRegistry() {
  if (Date.now() - _vregCache.at < 10 * 60 * 1000 && _vregCache.list.length) return _vregCache.list;
  // vendor_registry drives the DW#: vendor_code (vid for staging), sku_prefix, sku_range_start.
  const vrows = await pgRows(
    `select coalesce(nullif(private_label_name,''), vendor_name) disp, vendor_name, vendor_code,
            coalesce(sku_prefix,''), coalesce(sku_range_start,0)::text, coalesce(private_label_name,'')
       from vendor_registry
      where coalesce(is_active,true) and coalesce(vendor_name,'')<>''
        and coalesce(skip_shopify,false)=false
      order by disp`);
  // dominant short FM vid per series (prefix without the trailing dash), from the FM mirror.
  const fmrows = await pgRows(
    `select series, vid, count(*) n from filemaker_wallpaper
      where coalesce(series,'')<>'' and coalesce(vid,'')<>''
      group by series, vid`);
  const fmVidBySeries = {};
  for (const [series, vid, n] of fmrows) {
    const s = series.toUpperCase(); const c = +n || 0;
    if (!fmVidBySeries[s] || c > fmVidBySeries[s].n) fmVidBySeries[s] = { vid, n: c };
  }
  const list = vrows.map(([disp, vendor_name, vendor_code, sku_prefix, range, plabel]) => {
    const series = (sku_prefix || '').replace(/-+$/, '').toUpperCase();
    return {
      vendor: disp,                    // customer-facing name (private-label name wins) — Shopify vendor
      real_vendor: vendor_name,        // internal only (never shown / never customer-facing)
      vid: vendor_code,                // dw_unified staging vid
      sku_prefix: sku_prefix || null,
      sku_range_start: +range || 0,
      fm_vid: (fmVidBySeries[series] && fmVidBySeries[series].vid) || null,   // short FileMaker vid, best-effort
      private_label: !!plabel,
    };
  }).filter(v => v.vendor);
  _vregCache = { at: Date.now(), list };
  return list;
}

// ── CANONICAL DW# minting (matches the vendor onboarders) ────────────────────
// Scheme (verified against sanderson-onboard/build-payloads.mjs + create-grasscloth-masters):
//   dw_sku = <sku_prefix><N>, where N starts at max(sku_range_start, maxUsedInSeries+1) and skips
//   any number already used in that prefix's series. Series (FM) = prefix without the dash; the
//   numeric N is the FM "JS Pattern"; FileMaker auto-calcs `combo sku` = <Series>-<N> = the dw_sku.
// USED-NUMBER SOURCES (union, so a mint never collides): the live store mirror (shopify_products),
// this app's own new_items_staging (so back-to-back scans don't collide pre-sync), and the FM mirror
// (filemaker_wallpaper). If the vendor has no sku_prefix we CANNOT mint safely → return a clearly
// marked PROVISIONAL sku and flag it (never fabricate a colliding canonical DW#).
async function mintDwSku(vreg) {
  const prefix = (vreg.sku_prefix || '').trim();
  if (!prefix) {
    const provisional = 'PROV-' + (vreg.vid || 'VENDOR').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8) + '-' + Date.now().toString(36).toUpperCase();
    return { dw_sku: provisional, series: null, js_pattern: null, provisional: true,
      flag: `no sku_prefix in vendor_registry for "${vreg.real_vendor || vreg.vendor}" — assign one (registry.sku_prefix + sku_range_start) before go-live; used PROVISIONAL sku` };
  }
  const pfxNoDash = prefix.replace(/-+$/, '');
  const esc = prefix.replace(/'/g, "''");
  const escN = pfxNoDash.replace(/'/g, "''");
  // Pull the numeric tails already used under this prefix — each source queried SEPARATELY and
  // GUARDED, so a missing/lazily-created table (e.g. new_items_staging) can never silently zero out
  // the AUTHORITATIVE shopify_products count and cause a colliding mint. shopify_products is the
  // authoritative live-store mirror and MUST answer; if it errors we refuse to mint (flag), never guess.
  const used = new Set();
  const shop = await pgQuery(`select regexp_replace(dw_sku,'^${esc}','')::text from shopify_products where dw_sku ~ '^${esc}[0-9]+$'`);
  if (!shop.ok) {
    const provisional = 'PROV-' + pfxNoDash + '-' + Date.now().toString(36).toUpperCase();
    return { dw_sku: provisional, series: null, js_pattern: null, provisional: true,
      flag: `could not read shopify_products to verify the ${prefix} series is collision-free — used PROVISIONAL sku (do NOT go live until the real max-in-series is confirmed)` };
  }
  for (const r of shop.rows) { const n = parseInt(r[0], 10); if (Number.isFinite(n)) used.add(n); }
  // best-effort secondary sources: FM mirror (always present) + staging (guarded — may not exist yet)
  const fmq = await pgQuery(`select regexp_replace(coalesce(nullif(combo_sku,''),dw_sku),'^${escN}-','')::text from filemaker_wallpaper where coalesce(nullif(combo_sku,''),dw_sku) ~ '^${escN}-[0-9]+$'`);
  if (fmq.ok) for (const r of fmq.rows) { const n = parseInt(r[0], 10); if (Number.isFinite(n)) used.add(n); }
  const stg = await pgQuery(`select regexp_replace(dw_sku,'^${esc}','')::text from new_items_staging where to_regclass('public.new_items_staging') is not null and dw_sku ~ '^${esc}[0-9]+$'`);
  if (stg.ok) for (const r of stg.rows) { const n = parseInt(r[0], 10); if (Number.isFinite(n)) used.add(n); }
  // the vendor's own scrape catalog pre-assigns dw_skus to products not yet on Shopify/FM
  // (tres_tintas_catalog reserves DWTS-900001..901506) — those numbers are taken too.
  const cat = await pgQuery(`select catalog_table from vendor_registry where vendor_code='${String(vreg.vid || '').replace(/'/g, "''")}' limit 1`);
  const catTable = cat.ok && cat.rows[0] && /^[a-z0-9_]+$/.test(cat.rows[0][0] || '') ? cat.rows[0][0] : null;
  if (catTable) {
    const cq = await pgQuery(`select regexp_replace(dw_sku,'^${esc}','')::text from ${catTable} where to_regclass('public.${catTable}') is not null and dw_sku ~ '^${esc}[0-9]+$'`);
    if (cq.ok) for (const r of cq.rows) { const n = parseInt(r[0], 10); if (Number.isFinite(n)) used.add(n); }
  }
  const maxUsed = used.size ? Math.max(...used) : 0;
  let n = Math.max(vreg.sku_range_start || 0, maxUsed + 1, 1);
  while (used.has(n)) n++;
  // Every source above is a mirror that can lag the live systems by a day. JS Pattern is a TEXT field
  // in FileMaker, so "max" can't be asked of it reliably — instead confirm the chosen number is free
  // LIVE (exact FileMaker match + Shopify SKU prefix search) and step past any number that is taken.
  let liveFlag = null;
  for (let tries = 0; tries < 25; tries++) {
    const taken = await liveNumberTaken(pfxNoDash, n);
    if (taken === null) { liveFlag = `could not confirm ${prefix}${n} is free in live FileMaker/Shopify — verify before go-live`; break; }
    if (!taken) break;
    n++; while (used.has(n)) n++;
  }
  return { dw_sku: `${prefix}${n}`, series: pfxNoDash, js_pattern: String(n), provisional: false, flag: liveFlag };
}

// true = the number is already used live, false = free, null = a live source could not answer.
async function liveNumberTaken(series, n) {
  let fmTaken, shopTaken;
  try {
    const r = await FM.fmFind(FM_DB, FM_WP_CREATE_LAYOUT, [{ 'Series': '==' + series, 'JS Pattern': '==' + n }], { limit: 1, portal: [] });
    fmTaken = (r.records || r.data || []).length > 0;
  } catch (e) { fmTaken = /\b401\b|no records match/i.test(e.message) ? false : null; }
  try {
    const q = `{ productVariants(first: 10, query: "sku:${series}-${n}*") { edges { node { sku } } } }`;
    const b = await gql(q);
    if (!b.data) shopTaken = null;
    else shopTaken = b.data.productVariants.edges.some(e => new RegExp(`^${series}-${n}(\\D|$)`).test(e.node.sku || ''));
  } catch (e) { shopTaken = null; }
  if (fmTaken === true || shopTaken === true) return true;
  if (fmTaken === null || shopTaken === null) return null;
  return false;
}

// ── FileMaker WALLPAPER master fieldData builder ─────────────────────────────
// Maps captured fields onto the REAL WALLPAPER field names (confirmed live 2026-09-18 via
// fm.fieldMetadata on "*List Wallpapers - Full View"). CALC fields (combo sku, comboskuwithdash,
// seriesdashnumber, Dw Retail Price) are auto-derived by FileMaker — NEVER written here.
// Fields NOT on this create layout (Collection, Match, Price Code, roll length) are intentionally
// carried in dw_unified.specs + Shopify metafields instead, and surfaced in `unmapped` so it's explicit.
function buildFmMaster(p, mint, vreg) {
  const fd = { 'Record Type': 'Master' };
  if (mint.series) fd['Series'] = mint.series;              // e.g. DWAT  (combo sku auto = DWAT-<JS>)
  if (mint.js_pattern) fd['JS Pattern'] = mint.js_pattern;  // the numeric DW#
  const mfr = String(p.mfr || '').trim();
  if (mfr) { fd['Mfr Pattern'] = mfr; fd['mfr pattern number'] = mfr; }
  const name = String(p.name || '').trim();
  if (name) { fd['Name of Pattern'] = name; fd['Internal Description'] = name; }
  const color = String(p.color || '').trim();
  if (color) fd['Color of Pattern'] = color;
  if (p.width) fd['Width'] = /["]/.test(String(p.width)) ? String(p.width) : (String(p.width).match(/[\d.]+/) ? String(p.width).match(/[\d.]+/)[0] + '"' : String(p.width));
  if (p.substrate) fd['Content'] = String(p.substrate).trim();
  if (p.repeat) fd['Repeat'] = String(p.repeat).trim();
  if (p.how_sold) fd['Sold Per'] = String(p.how_sold).trim();
  if (p.price && +p.price > 0) fd['Retail Price'] = String(p.price);
  const fmVid = (vreg && vreg.fm_vid) || null;
  if (fmVid) fd['vid'] = fmVid;
  // spec fields with no home on this layout — kept in Shopify metafields + staging specs, reported honestly
  const unmapped = {};
  if (p.collection) unmapped.collection = p.collection;      // "Collection" not on create layout
  if (p.roll_length) unmapped.roll_length = p.roll_length;   // no roll-length field on create layout
  if (p.pattern_match) unmapped.pattern_match = p.pattern_match; // no Match field on create layout
  if (p.price_code) unmapped.price_code = p.price_code;      // no Price Code field on create layout
  if (p.material) unmapped.material = p.material;
  return { fieldData: fd, unmapped, vid_flag: fmVid ? null : `no FileMaker vid resolved for series ${mint.series || '?'} — set WALLPAPER.vid manually or add a vid mapping` };
}

// Create a NEW item (arbitrary vendor) as a Shopify DRAFT + a dw_unified staging row + a REAL
// FileMaker WALLPAPER master. Draft-only, never auto-published (going live is a separate gated step).
// dryRun (default) returns a full 3-system preview and writes nothing.
// TK-12228 test seam: stands in for createNewItem under --test ONLY (no Shopify / dw_unified / FileMaker).
function testStubCreate(p, dryRun) {
  const mfr = String(p.mfr || '').trim(), vendor = String(p.vendor || '').trim();
  if (!mfr || !vendor) return { ok: false, err: 'mfr + vendor required — pick a vendor first' };
  const dw = 'TEST-' + mfr.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
  const preview = { title: `${p.name || mfr} | ${vendor}`, dw_sku: dw, mfr, vendor, vid: p.vid || null, color: p.color || '', price: null, status: 'draft',
    photos: Array.isArray(p._photos64) ? p._photos64.length : 0, provisional_sku: false, duplicate_of: null,
    shopify: { vendor, product_type: 'Wallcovering', status: 'draft', variants: [{ option1: 'Roll', sku: dw, price: '(quote)' }, { option1: 'Sample', sku: dw + '-Sample', price: '4.25' }] },
    staging: { table: 'new_items_staging', dw_sku: dw, mfr_sku: mfr, vendor, vid: '', pattern_name: p.name || '', color: p.color || '', price: null, specs: {} },
    filemaker: { db: 'TEST', layout: 'TEST', fieldData: {}, auto_calc: {} }, flags: ['TEST MODE — nothing written'] };
  if (dryRun) return { ok: true, dryRun: true, preview };
  return { ok: true, test_stub: true, product_id: null, dw_sku: dw, title: preview.title, status: 'draft', preview };
}
async function createNewItem(p, b64, dryRun) {
  const mfr = String(p.mfr || '').trim(); const vendor = String(p.vendor || '').trim();
  if (!mfr || !vendor) return { ok: false, err: 'mfr + vendor required — pick a vendor first' };
  // TWO-PHOTO / identity rail (defense-in-depth behind the UI gate): a new-SKU capture must carry a
  // FRONT photo, and its mfr# identity must come from front-OCR, back-OCR, or manual entry (mfr, above).
  // Back is optional only when the front already yielded the code — the UI enforces which; here we just
  // guarantee a front image + a real mfr# so an item can never be created with no product photo/identity.
  // Enforced UNCONDITIONALLY server-side (TK-11962): the old `if (p.require_two)` let a raw
  // POST that simply omitted the flag opt OUT of its own validation and create a Shopify DRAFT
  // + FileMaker master with zero photos and an arbitrary mfr#. The UI always sends require_two,
  // and there is no legitimate no-photo create path, so the gate is now the secure default.
  {
    const nPhotos = Array.isArray(p._photos64) ? p._photos64.length : (b64 ? 1 : 0);
    if (nPhotos < 1) return { ok: false, err: 'front (pattern) photo required' };
    if (!p.back_present && !mfr) return { ok: false, err: 'no back photo and no manual mfr# — capture the back label or enter the mfr#/SKU' };
  }
  // Resolve the chosen vendor from the canonical registry (drives DW# + Shopify vendor + FM vid).
  const registry = await getVendorsRegistry().catch(() => []);
  let vreg = findVendorReg(registry, vendor, p.vid);
  if (!vreg) {
    // vendor typed/spoken but not in the registry — still allow, but flag (no canonical prefix known)
    vreg = { vendor, real_vendor: vendor, vid: p.vid || '', sku_prefix: null, sku_range_start: 0, fm_vid: null, private_label: false };
  }
  // dedup: refuse if the mfr# already exists in the catalog (attach instead, don't duplicate)
  const dup = CATALOG.find(x => x.mfr && nmfr(x.mfr) === nmfr(mfr));
  // CANONICAL DW# minting — next sequential in the vendor's series (never a naive prefix+mfr concat).
  // Honor an explicit dw_sku override only if the client passed one.
  const mint = String(p.dw_sku || '').trim()
    ? { dw_sku: String(p.dw_sku).trim(), series: (String(p.dw_sku).split('-')[0] || null), js_pattern: (String(p.dw_sku).split('-')[1] || null), provisional: false, flag: null }
    : await mintDwSku(vreg);
  const dwsku = mint.dw_sku;
  const name = String(p.name || '').trim(); const color = String(p.color || '').trim();
  const price = p.price && +p.price > 0 ? String(p.price) : null;
  // Title format: Pattern Name Real Color Name | Brand Name  (never "Unknown"; fall back mfr → color)
  const lead = name || mfr || color;
  const title = [lead, color && color !== lead ? color : '', '|', vreg.vendor].filter(Boolean).join(' ');
  const material = (p.material || '').trim() || 'Wallcovering';
  const fm = buildFmMaster(p, mint, vreg);
  const specsObj = { material, collection: p.collection || '', width: p.width || '', roll_length: p.roll_length || '',
    repeat: p.repeat || '', pattern_match: p.pattern_match || '', substrate: p.substrate || '',
    how_sold: p.how_sold || '', price_code: p.price_code || '', notes: p.notes || '' };
  const flags = [mint.flag, fm.vid_flag, dup ? `mfr# ${mfr} already in catalog as ${dup.dw_sku || dup.product_id}` : null].filter(Boolean);
  // FULL 3-system preview — exactly what Shopify / dw_unified / FileMaker WOULD receive.
  const preview = {
    title, dw_sku: dwsku, mfr, vendor: vreg.vendor, vid: vreg.vid || null, color, price, status: 'draft',
    photos: Array.isArray(p._photos64) ? p._photos64.length : (b64 ? 1 : 0),
    provisional_sku: !!mint.provisional, duplicate_of: dup ? (dup.dw_sku || dup.product_id) : null,
    shopify: { vendor: vreg.vendor, product_type: material, status: 'draft',
      variants: [{ option1: 'Roll', sku: dwsku, price: price || '(quote — draft, no price yet)' },
        { option1: 'Sample', sku: dwsku + '-Sample', price: '4.25' }] },
    staging: { table: 'new_items_staging', dw_sku: dwsku, mfr_sku: mfr, vendor: vreg.vendor, vid: vreg.vid || '', pattern_name: name, color, price, specs: specsObj },
    filemaker: { db: FM_DB, layout: FM_WP_CREATE_LAYOUT, fieldData: fm.fieldData,
      auto_calc: { 'combo sku': mint.series && mint.js_pattern ? `${mint.series}-${mint.js_pattern}` : '(needs Series + JS Pattern)' },
      unmapped_kept_in_metafields: fm.unmapped },
    flags,
  };
  if (dup) return { ok: false, duplicate: true, preview, err: `mfr# ${mfr} already exists (${dup.dw_sku || dup.product_id}) — add a photo to it instead` };
  if (dryRun) return { ok: true, dryRun: true, preview };
  // ── COMMIT: PostgreSQL-staging + Shopify draft + FileMaker master (draft-only, never published) ──
  try {
    // Each metafield MUST carry the type its store DEFINITION declares (e.g. custom.material is
    // multi_line_text_field) or Shopify 422s the whole create. Undefined keys default to single_line.
    const mfTypes = await getMetafieldTypeMap();
    const mf = (ns, key, val) => val ? { namespace: ns, key, value: String(val), type: (mfTypes[`${ns}.${key}`] || 'single_line_text_field') } : null;
    const variants = [{ option1: 'Roll', sku: dwsku, inventory_management: 'shopify', inventory_quantity: 0 },
      { option1: 'Sample', sku: dwsku + '-Sample', price: '4.25', inventory_management: 'shopify', inventory_quantity: 0 }];
    if (price) variants[0].price = price;
    const payload = { product: {
      title, vendor: vreg.vendor, product_type: material, status: 'draft',
      tags: ['new-from-scan', 'display_variant', mint.provisional ? 'Provisional-SKU' : '', color ? ('color:' + color) : ''].filter(Boolean).join(', '),
      options: [{ name: 'Size' }], variants,
      images: (Array.isArray(p._photos64) && p._photos64.length ? p._photos64 : (b64 ? [b64] : []))
        .map((att, i) => ({ attachment: att, filename: `${dwsku}${i ? '-' + i : ''}.jpg`, position: i + 1 })),
      metafields: [mf('custom', 'manufacturer_sku', mfr), mf('dwc', 'manufacturer_sku', mfr),
        mf('custom', 'pattern_name', name), mf('custom', 'color', color), mf('global', 'Brand', vreg.vendor),
        mf('global', 'Collection', p.collection), mf('global', 'width', p.width), mf('global', 'length', p.roll_length),
        mf('global', 'Pattern-Repeat', p.repeat), mf('global', 'Match', p.pattern_match), mf('global', 'Content', p.substrate),
        mf('dwc', 'sold_by', p.how_sold), mf('custom', 'price_code', p.price_code), mf('custom', 'material', material)].filter(Boolean)
    } };
    const cr = await shopifyReq('POST', '/products.json', payload);
    if (cr.status < 200 || cr.status >= 300) return { ok: false, err: `Shopify create HTTP ${cr.status}: ${(cr.raw || '').slice(0, 160)}` };
    const pid = cr.body && cr.body.product && cr.body.product.id;
    // stage into dw_unified (additive table — does NOT touch canonical catalog rows).
    // Embed every user-controlled value via pgLit (standard '' -escaped SQL literals). The old
    // $$-dollar-quote escape was broken: replace(/\$\$/g,'$') is non-idempotent and fails on runs
    // of ≥3 '$' (esc("$$$")→"$$" still contains $$), so a crafted value like dw_sku="$$$" could
    // close the surrounding $$…$$ literal early and inject SQL into DW_DB. pgLit has no such hatch.
    const specsJson = JSON.stringify(specsObj);
    // price is user input (String(p.price)); emit it only as a validated finite number, never a raw
    // interpolated string — a numeric column can't take a quoted literal, so it must be gated as a number.
    const priceLit = (price != null && Number.isFinite(+price)) ? +price : 'NULL';
    // pid is a Shopify bigint id: validate it as safe-integer digits rather than TRUST its type. A
    // string pid (API-shape change / error body / a different code path) must never reach raw SQL
    // interpolation; emitting bare validated digits also avoids Number() precision loss above 2^53.
    // (Independent review flagged this raw ${pid} interpolation as the one remaining vector.)
    const pidStr = (typeof pid === 'number' && Number.isSafeInteger(pid)) ? String(pid) : (typeof pid === 'string' ? pid : '');
    const pidLit = /^\d{1,19}$/.test(pidStr) ? pidStr : 'NULL';
    const stageSQL = `insert into new_items_staging (dw_sku, mfr_sku, vendor, vid, pattern_name, color, price, specs, shopify_product_id, created_via)
      values (${pgLit(dwsku)},${pgLit(mfr)},${pgLit(vreg.vendor)},${pgLit(vreg.vid || '')},${pgLit(name)},${pgLit(color)},${priceLit},${pgLit(specsJson)}::jsonb,${pidLit},'scan') on conflict do nothing`;
    const stageRun = () => new Promise(resolve => execFile(PSQL, ['-v', 'ON_ERROR_STOP=1', '-d', DW_DB, '-c', 'create table if not exists new_items_staging (id bigserial primary key, dw_sku text, mfr_sku text, vendor text, vid text, pattern_name text, color text, price numeric, shopify_product_id bigint, created_via text, created_at timestamptz default now()); alter table new_items_staging add column if not exists specs jsonb; ' + stageSQL], { timeout: 8000 },
      (err, _out, stderr) => resolve(err ? { committed: false, err: (String(stderr || '').trim() || err.message).slice(0, 200) } : { committed: true, table: 'new_items_staging' })));
    let dwResult = await stageRun();
    if (!dwResult.committed) dwResult = await stageRun();
    if (!dwResult.committed) console.error(`[dw_unified] staging write FAILED for ${dwsku}: ${dwResult.err}`);
    // FileMaker WALLPAPER master — real create (dedupe on Series + Mfr Pattern so a re-run never dupes).
    let fmResult = { committed: false, skipped: 'FileMaker disabled or not configured' };
    if (FM_ENABLED() && FM.fmCreate) {
      const dedupe = (mint.series && mfr) ? { 'Series': mint.series, 'Mfr Pattern': '==' + mfr } : null;
      fmResult = await FM.fmCreate(FM_DB, FM_WP_CREATE_LAYOUT, fm.fieldData, { dryRun: false, dedupe }).catch(e => ({ committed: false, err: e.message }));
    }
    if (pid) CATALOG.push({ product_id: pid, title, status: 'DRAFT', dw_sku: dwsku, mfr, price, image: null, done: false });
    return { ok: true, product_id: pid, dw_sku: dwsku, title, status: 'draft', filemaker: fmResult, dw_unified: dwResult, flags, preview };
  } catch (e) { return { ok: false, err: e.message }; }
}

// In-memory index of ALL non-archived Fentucci products (for "find any SKU" lookup).
let CATALOG = [], INDEX = [];
let _diskCacheSig = null;   // TK-11962: size+mtime of the disk cache last loaded into CATALOG (skip redundant reparse)
const nmfr = s => (s || '').trim().toUpperCase().replace(/T$/, ''); // normalize mfr (WOS3467T == wos3467)
function rebuildIndex() {
  const haveSku = new Set(CATALOG.map(x => (x.dw_sku || '').toUpperCase()));
  const haveMfr = new Set(CATALOG.map(x => nmfr(x.mfr)).filter(Boolean));
  // a sheet item is genuinely NEW only if neither its GRS sku NOR its mfr# already exists (dedup)
  const sheetOnly = SHEET.filter(s => !haveSku.has(s.grs.toUpperCase()) && !(s.mfr && haveMfr.has(nmfr(s.mfr)))).map(s => ({
    product_id: null, title: sheetTitle(s), status: 'NEW', dw_sku: s.grs, mfr: s.mfr || '',
    price: s.price || null, image: s.img1 && /^https?:/.test(s.img1) ? s.img1 : null,
    collections: ['TWIL Naturals', 'Grasscloth'], needs_create: true, done: false
  }));
  INDEX = CATALOG.concat(sheetOnly);
  console.log(`lookup index: ${CATALOG.length} live + ${sheetOnly.length} new-from-sheet = ${INDEX.length}`);
}
// ── all-dw internal full-catalog feed ─────────────────────────────────────────
// The browsable CATALOG used to be Fentucci-only. It now sources the WHOLE dw_unified
// catalog (every non-archived SKU, all vendors) from all.designerwallcoverings.com's
// auth-gated /api/catalog-full — the single leak-sanitized source of truth. So the
// scanner's lookup/browse ("/api/lookup", "/api/twil") and the incoming-sample photo
// match cover every line, not just one. Fallback chain keeps it robust: live feed →
// last-good disk cache → legacy Fentucci Shopify crawl (never leaves the app empty).
const ALL_DW_URL = process.env.ALL_DW_URL || 'http://127.0.0.1:9958';
const ALL_DW_AUTH = process.env.ALL_DW_AUTH || 'admin:DW2024!';
const CATALOG_CACHE = path.join(DATA, 'all-catalog.json');

function fetchAllDwCatalog() {
  return new Promise((resolve, reject) => {
    const u = new URL(ALL_DW_URL + '/api/catalog-full');
    const lib = u.protocol === 'https:' ? https : http;
    const req = lib.request({
      hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET',
      headers: { 'Authorization': 'Basic ' + Buffer.from(ALL_DW_AUTH).toString('base64'), 'Accept-Encoding': 'gzip' },
    }, resp => {
      if (resp.statusCode !== 200) { resp.resume(); return reject(new Error('feed HTTP ' + resp.statusCode)); }
      const stream = /gzip/.test(resp.headers['content-encoding'] || '') ? resp.pipe(zlib.createGunzip()) : resp;
      const chunks = [];
      stream.on('data', c => chunks.push(c));
      stream.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch (e) { reject(e); } });
      stream.on('error', reject);
    });
    req.setTimeout(45000, () => req.destroy(new Error('feed timeout')));
    req.on('error', reject); req.end();
  });
}

// Map an all-dw feed row → the flat item shape the scanner UI cards expect.
function feedRowToItem(r) {
  return {
    product_id: r.product_id || null, title: r.title || '', status: r.status || '',
    dw_sku: r.sku || '', mfr: r.mfr_number || '', price: r.price != null ? String(r.price) : null,
    image: r.image || null, vendor: r.vendor || '',
    collections: [].concat(r.styles || [], r.materials || []),
    keep_images: true, done: false,
  };
}

async function buildCatalog() {
  // 1) live feed
  try {
    const feed = await fetchAllDwCatalog();
    if (feed && Array.isArray(feed.rows) && feed.rows.length) {
      CATALOG = feed.rows.map(feedRowToItem);
      try {
        // Atomic write: this cache is a multi-hundred-MB JSON; an in-place writeFileSync that is
        // interrupted (OOM/restart/disk-full mid-write) leaves a TRUNCATED file that the feed-down
        // fallback (branch 2 below) would then JSON.parse and fail on every 15-min tick. Write to a
        // sibling .tmp on the same filesystem, then rename() — rename is atomic, so a reader ever
        // only sees the complete old file or the complete new one, never a torn one.
        const tmp = CATALOG_CACHE + '.tmp';
        fs.writeFileSync(tmp, JSON.stringify(feed));
        fs.renameSync(tmp, CATALOG_CACHE);
        // Record the signature of the file we just wrote so a later feed-down tick recognizes that
        // CATALOG already reflects this exact cache and skips a redundant ~hundreds-of-MB reparse
        // (the TK-11962 guard keys on size+mtime). Without this, the first feed-down tick after every
        // refresh needlessly re-reads+re-parses the file it just produced — costly with a flapping feed.
        const st = fs.statSync(CATALOG_CACHE);
        _diskCacheSig = st.size + ':' + st.mtimeMs;
      } catch (e) { /* cache best-effort */ }
      console.log(`catalog indexed: ${CATALOG.length} products from all-dw feed`);
      return rebuildIndex();
    }
    throw new Error('feed empty');
  } catch (e) {
    console.log('all-dw feed unavailable:', e.message);
  }
  // 2) last-good disk cache
  // TK-11962: the feed is down, so this branch runs on EVERY 15-min interval. The cache is a
  // multi-hundred-MB / ~250k-row JSON; re-doing readFileSync+JSON.parse+map synchronously each
  // time spikes the heap ~2.3GB and GC-thrashes the event loop for tens of seconds (scanner goes
  // unresponsive). Skip the reparse when CATALOG already reflects this exact cache file (size+mtime).
  // stat-BEFORE-read is deliberate: a concurrent writer that finishes after our read bumps mtime
  // past our stored sig, so the next tick reparses (read-then-stat could tag torn content and skip
  // forever).
  let sig = null;
  try {
    const st = fs.statSync(CATALOG_CACHE);
    sig = st.size + ':' + st.mtimeMs;
    if (CATALOG.length && _diskCacheSig === sig) return;   // unchanged since last load → nothing to do
    const feed = JSON.parse(fs.readFileSync(CATALOG_CACHE, 'utf8'));
    if (feed && Array.isArray(feed.rows) && feed.rows.length) {
      CATALOG = feed.rows.map(feedRowToItem);
      _diskCacheSig = sig;
      console.log(`catalog indexed: ${CATALOG.length} products from disk cache (feed down)`);
      return rebuildIndex();
    }
  } catch (e) {
    if (e.code !== 'ENOENT') {
      // Readable-but-unparseable/erroring cache (torn write, disk full, EACCES/EIO): record its
      // signature so the SAME bad file isn't re-parsed (and re-thrashed) every 15min — a genuine
      // rewrite bumps mtime → sig differs → we retry. And never downgrade a healthy in-memory
      // catalog to the Fentucci-only fallback on a transient read error.
      if (sig) _diskCacheSig = sig;
      console.error('catalog cache unreadable:', e.message);
      if (CATALOG.length) return;
    }
    // ENOENT (no cache yet) or empty in-memory catalog → fall through to the legacy fallback
  }
  // 3) legacy Fentucci-only Shopify crawl — never leave the scanner empty
  return buildCatalogFentucci();
}

async function buildCatalogFentucci() {
  const Q = `query($c:String){products(first:100,query:"vendor:Fentucci",after:$c){pageInfo{hasNextPage endCursor}
    edges{node{legacyResourceId title status
      imgs:images(first:1){edges{node{src}}}
      cols:collections(first:8){edges{node{title}}}
      mf:metafield(namespace:"custom",key:"manufacturer_sku"){value}
      mf2:metafield(namespace:"dwc",key:"manufacturer_sku"){value}
      v:variants(first:3){edges{node{sku title price}}}}}}}`;
  let out = [], cursor = null, pages = 0;
  try {
    if (!TOKEN) { console.log('catalog fallback skipped — no Shopify token'); return; }
    while (pages < 60) {
      const r = await gql(Q, { c: cursor });
      const pr = r.data && r.data.products;
      if (!pr) break;
      for (const e of pr.edges) {
        const n = e.node;
        if (n.status === 'ARCHIVED') continue;
        const roll = n.v.edges.map(x => x.node).find(x => !(x.sku || '').endsWith('-Sample') && (x.title || '').toLowerCase() !== 'sample') || (n.v.edges[0] && n.v.edges[0].node);
        out.push({
          product_id: n.legacyResourceId, title: n.title, status: n.status,
          dw_sku: (roll || {}).sku || '', mfr: (n.mf || {}).value || (n.mf2 || {}).value || '',
          price: (roll || {}).price || null,
          image: (n.imgs.edges[0] && n.imgs.edges[0].node.src) || null,
          collections: (n.cols.edges || []).map(c => c.node.title),
          done: false
        });
      }
      pages++;
      if (pr.pageInfo.hasNextPage) cursor = pr.pageInfo.endCursor; else break;
    }
    CATALOG = out;
    console.log(`catalog indexed: ${CATALOG.length} Fentucci products (legacy fallback)`);
    rebuildIndex();
  } catch (e) { console.log('catalog index error:', e.message); }
}

// Map a Shopify product GraphQL node → the flat item shape the UI cards expect.
function nodeToItem(n, extra) {
  const roll = n.v.edges.map(x => x.node).find(x => !(x.sku || '').endsWith('-Sample') && (x.title || '').toLowerCase() !== 'sample') || (n.v.edges[0] && n.v.edges[0].node);
  return Object.assign({
    product_id: n.legacyResourceId, title: n.title, status: n.status,
    dw_sku: (roll || {}).sku || '', mfr: (n.mf || {}).value || (n.mf2 || {}).value || '',
    price: (roll || {}).price || null,
    image: (n.imgs.edges[0] && n.imgs.edges[0].node.src) || null,
    vendor: n.vendor || '', done: false
  }, extra || {});
}

// Live store-wide search: one GraphQL call, wildcard match on sku/title/vendor.
// keep_images:true is stamped on every result so a photo update on an arbitrary
// live product ADDS as featured without wiping existing imagery.
const _PNODE = `legacyResourceId title status vendor
  imgs:images(first:1){edges{node{src}}}
  cols:collections(first:6){edges{node{title}}}
  mf:metafield(namespace:"custom",key:"manufacturer_sku"){value}
  mf2:metafield(namespace:"dwc",key:"manufacturer_sku"){value}
  v:variants(first:5){edges{node{sku title price}}}`;
// ── Local Q&A (exo ring text model via the shared lib, $0) ────────────────────
// TK-12090 Lane P: was a direct Ollama :11434 call (retired). Now the shared lib's textChat;
// an unreachable ring rejects with a real error so the endpoint says "unreachable" honestly.
const OLLAMA_MODEL = process.env.LLM_MODEL || 'mlx-community/Qwen3.6-27B-4bit';   // name kept for callers
async function ollamaAsk(prompt) {
  const lib = await exoLib();
  const r = await lib.textChat({ prompt, model: OLLAMA_MODEL, timeoutMs: 30000, maxTokens: 160 });
  if (!r.ok) throw new Error(r.error || 'local LLM not measured');
  return String(r.text || '').replace(/<think>[\s\S]*?<\/think>/g, '');
}
// Pull a product's facts (basics + spec-like metafields) → a compact context block for the LLM.
async function productContext(productId) {
  if (!productId) return null;
  const gid = `gid://shopify/Product/${String(productId).replace(/\D/g, '')}`;
  const Q = `query($id:ID!){ product(id:$id){ title vendor productType tags
    variants(first:3){edges{node{sku price}}}
    metafields(first:50){edges{node{key value}}} } }`;
  const r = await gql(Q, { id: gid });
  const node = r.data && r.data.product;
  if (!node) return null;
  const vs = node.variants.edges.map(e => e.node);
  const v = vs.find(x => x.sku && !/-sample$/i.test(x.sku)) || vs[0] || {};   // prefer the main (non-sample) variant
  const facts = [`Title: ${node.title}`];
  if (node.vendor) facts.push(`Vendor: ${node.vendor}`);
  if (node.productType) facts.push(`Type: ${node.productType}`);
  if (v.price) facts.push(`Price: $${v.price}`);
  if (v.sku) facts.push(`SKU: ${v.sku}`);
  const WANT = /(width|content|repeat|fire|flam|weight|care|material|origin|backing|finish|colou?r|usage|durab|clean|length|yard|coverage|railroad|match)/i;
  const seen = new Set();
  for (const e of (node.metafields.edges || [])) {
    const k = e.node.key, val = (e.node.value || '').toString();
    if (!val || val.length > 120 || /^https?:/.test(val) || /^[[{]/.test(val)) continue;   // skip urls / json blobs
    if (WANT.test(k) && !seen.has(k)) { seen.add(k); facts.push(`${k.replace(/_/g, ' ')}: ${val}`); }
  }
  if (node.tags && node.tags.length) facts.push(`Tags: ${node.tags.slice(0, 12).join(', ')}`);
  return facts.join('\n');
}

async function shopifySearch(q) {
  // If the query is a single code-like token (has a digit), try the manufacturer-SKU
  // index FIRST — a scanned vendor code (WDW2310) resolves to its DW house SKU (CHC-…)
  // and we search by that, since Shopify can't search the mfr# metafield by value.
  const one = q.trim();
  if (!/\s/.test(one) && /\d/.test(one)) {
    const house = mfrToHouseSku(one);
    if (house && mfrNorm(house) !== mfrNorm(one)) q = house;   // rewrite to the house SKU
  }
  const terms = q.split(/\s+/).filter(Boolean).slice(0, 4)
    .map(t => t.replace(/["\\():*]/g, '')).filter(Boolean);
  if (!terms.length) return [];
  // 1) product search (sku/title/vendor) + 2) products inside any matching COLLECTION — in parallel
  const pstr = terms.map(t => `(sku:*${t}* OR title:*${t}* OR vendor:*${t}*)`).join(' AND ');
  const cstr = terms.map(t => `title:*${t}*`).join(' AND ');
  const PQ = `query($q:String){ products(first:50, query:$q){ edges{node{ ${_PNODE} } } } }`;
  const CQ = `query($q:String){ collections(first:5, query:$q){ edges{node{ title products(first:40){edges{node{ ${_PNODE} }}} }}} }`;
  const [pr, cr] = await Promise.all([gql(PQ, { q: pstr }), gql(CQ, { q: cstr })]);
  const byId = new Map();
  const add = (n, fromCol) => {
    if (!n || n.status === 'ARCHIVED') return;
    let it = byId.get(n.legacyResourceId);
    if (!it) { it = nodeToItem(n, { keep_images: true, scope: 'shopify' }); it.collections = (n.cols ? n.cols.edges.map(e => e.node.title) : []); byId.set(n.legacyResourceId, it); }
    if (fromCol && !it.collections.includes(fromCol)) it.collections.push(fromCol);
  };
  for (const e of ((pr.data && pr.data.products && pr.data.products.edges) || [])) add(e.node);
  for (const ce of ((cr.data && cr.data.collections && cr.data.collections.edges) || []))
    for (const pe of ce.node.products.edges) add(pe.node, ce.node.title);
  return Array.from(byId.values()).slice(0, 80);
}

// ── Local dw_unified mirror: pattern-similarity search ───────────────────────
// This app carries zero runtime deps (see package.json), so — exactly like the
// Swift OCR binary — we reach Postgres by shelling to `psql` rather than pulling
// in a driver. The local mirror IS the whole store (~169k products) and, crucially,
// carries the AI-enriched `tags` (colors / style / motif / material) that turn a
// VLM's pattern read into real "similar items" instead of a brittle title match.
const PSQL = [process.env.PSQL, '/opt/homebrew/opt/postgresql@14/bin/psql',
  '/opt/homebrew/bin/psql', '/usr/local/bin/psql', '/usr/bin/psql']
  .find(p => p && fs.existsSync(p)) || 'psql';
const DW_DB = process.env.DW_UNIFIED_DB || 'dw_unified';
// words too generic to discriminate (every wallcovering tag-set carries them)
const SIM_STOP = new Set(['wallcovering', 'wallcoverings', 'wallpaper', 'fabric', 'fabrics',
  'multi', 'color', 'colour', 'interior', 'designer', 'showroom', 'line', 'non', 'woven',
  'nonwoven', 'needs', 'image', 'price', 'width', 'pattern', 'wall', 'background']);

// VLM attribute object → a clean, deduped, injection-safe term list for tag matching.
// Every term is reduced to [a-z0-9 &-] (quotes/semicolons/backslashes stripped), so the
// terms can be embedded straight into the ILIKE patterns below with no escape risk.
function similarTerms(attrs) {
  const raw = [];
  const push = v => { if (v) String(v).split(/[,/;&]| and /i).forEach(t => raw.push(t)); };
  push(attrs.motif); push(attrs.style); push(attrs.material); push(attrs.background);
  (attrs.colors || []).forEach(push); push(attrs.pattern); push(attrs.type);
  const seen = new Set(), out = [];
  for (let t of raw) {
    t = String(t).toLowerCase().replace(/[^a-z0-9 &-]/g, ' ').replace(/\s+/g, ' ').trim();
    if (t.length < 3 || SIM_STOP.has(t) || seen.has(t)) continue;
    seen.add(t); out.push(t);
    if (out.length >= 8) break;
  }
  return out;
}

// Rank ACTIVE products by how many recognized attribute-terms appear in their enriched
// tags / title / pattern-name, newest-priced first. Returns standard card-shaped items
// (keep_images:true so updating a photo on a match ADDS as featured, never wipes).
function unifiedSimilar(terms) {
  return new Promise(resolve => {
    const t = terms.filter(Boolean).slice(0, 8);
    if (!t.length) return resolve([]);
    const hay = "lower(coalesce(tags,'')||' '||coalesce(title,'')||' '||coalesce(pattern_name,''))";
    // score = how many recognized terms this row matches; the WHERE is just score>0 (≥1 match).
    const score = t.map(x => `(case when ${hay} like '%${x}%' then 1 else 0 end)`).join('+');
    const SQL = `select regexp_replace(coalesce(shopify_id,''),'\\D','','g') as product_id,
        regexp_replace(coalesce(dw_sku,''),'-?sample$','','i'), coalesce(mfr_sku,''), coalesce(title,''), coalesce(vendor,''),
        coalesce(case when price>4.25 then price end, retail_price, price),
        coalesce(image_url,''), upper(coalesce(status,'')), (${score}) as score
      from shopify_products
      where status ilike 'active' and image_url is not null and image_url <> '' and (${score}) > 0
      order by score desc, (coalesce(price,0)>4.25) desc, updated_at_shopify desc nulls last
      limit 48`;
    execFile(PSQL, ['-d', DW_DB, '-tAF', '\t', '-c', SQL],
      { timeout: 8000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
        if (err) return resolve([]);
        const items = (stdout || '').split('\n').filter(Boolean).map(line => {
          const [product_id, dw_sku, mfr, title, vendor, price, image, status, sc] = line.split('\t');
          return {
            product_id: product_id || null, dw_sku: dw_sku || '', mfr: mfr || '',
            title: title || '', vendor: vendor || '',
            price: price ? parseFloat(price) : null, image: image || null,
            status: status || '', score: parseInt(sc, 10) || 0, keep_images: true, scope: 'unified'
          };
        });
        resolve(items);
      });
  });
}

// ── "BEST ID": identify a scanned code against the WHOLE unified catalog BEFORE any Shopify or
// FileMaker lookup. dw_sku_crossref (267k) is the key — it shares FileMaker's code vocabulary
// (crossref.mfr_sku == FMP "Mfr Pattern", crossref.internal_sku == FMP "combo sku"), so the
// identity it returns is also the FileMaker search key. Image/pattern enrich best-effort from
// the 173k shopify_products (the vendor_catalog dw_sku namespace doesn't join cleanly). $0 local psql.
function identifyUnified(code) {
  return new Promise(resolve => {
    const norm = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');   // alnum-only → injection-safe
    if (norm.length < 3) return resolve(null);
    // CTE resolves ONE crossref row first, then a single lateral image lookup (no per-row blowup).
    const SQL = `with m as (
        select internal_sku, internal_sku_dash, mfr_sku, vendor_code, coalesce(vendor_name,'') vendor_name,
               upper(regexp_replace(split_part(mfr_sku,' ',1),'[^A-Za-z0-9]','','g')) mfr_norm
        from dw_sku_crossref
        where upper(regexp_replace(split_part(mfr_sku,' ',1),'[^A-Za-z0-9]','','g'))='${norm}'
           or upper(regexp_replace(coalesce(internal_sku,''),'[^A-Za-z0-9]','','g'))='${norm}'
           or upper(regexp_replace(coalesce(internal_sku_dash,''),'[^A-Za-z0-9]','','g'))='${norm}'
        order by (upper(regexp_replace(split_part(mfr_sku,' ',1),'[^A-Za-z0-9]','','g'))='${norm}') desc
        limit 1)
      select m.internal_sku, m.internal_sku_dash, m.mfr_sku, m.vendor_code, m.vendor_name,
             coalesce(sp.title,''), coalesce(sp.image_url,''), coalesce(sp.pattern_name,'')
      from m left join lateral (
        select title, image_url, pattern_name from shopify_products
        where upper(regexp_replace(coalesce(mfr_sku,''),'[^A-Za-z0-9]','','g'))=m.mfr_norm
          and image_url is not null and image_url<>'' limit 1) sp on true`;
    execFile(PSQL, ['-d', DW_DB, '-tAF', '\t', '-c', SQL],
      { timeout: 8000, maxBuffer: 2 * 1024 * 1024 }, (err, stdout) => {
        if (err) return resolve(null);
        const line = (stdout || '').split('\n').filter(Boolean)[0];
        if (!line) return resolve(null);
        const [internal_sku, internal_dash, mfr_sku, vendor_code, vendor, title, image, pattern] = line.split('\t');
        resolve({
          internal_sku: internal_sku || null, internal_sku_dash: internal_dash || null,
          mfr_sku: mfr_sku || null, mfr_code: (mfr_sku || '').split(' ')[0] || null,
          vendor_code: vendor_code || null, vendor: vendor || null,
          title: title || null, image: image || null, pattern: pattern || null, matched: norm
        });
      });
  });
}

// ── Discontinued-SKU resolver + live successor ───────────────────────────────
// Ported from ~/.claude/skills/substitutefinder/find.py (resolve_successor + target query).
// A typed SKU like CHC-216830 (or CHC216830, or CHC-216830-SAMPLE) resolves — dash/punct-
// insensitively, and by de-dashed PREFIX so the -SAMPLE variant is caught — to a stored row
// (e.g. CHC-216830-SAMPLE / DELETED_FROM_SHOPIFY). If that row is NOT active/draft it's flagged
// discontinued and linked to its live SUCCESSOR: the nearest ACTIVE product whose name matches
// the dead item's real pattern — resolved via a COPY-OF-<pattern> mfr hint (often only on a
// SIBLING sharing the dead title) then the dead base title itself. $0 local psql.
const _pgLit = pgLit;   // shared single-quote SQL-literal escaper (defined near the psql helpers above)
function _pgq(sql) {
  return new Promise(resolve => {
    execFile(PSQL, ['-d', DW_DB, '--csv', '-v', 'ON_ERROR_STOP=1', '-c', sql],
      { timeout: 8000, maxBuffer: 4 * 1024 * 1024 }, (err, out) => {
        if (err) return resolve([]);
        const lines = String(out || '').split('\n').filter(Boolean);
        if (lines.length < 1) return resolve([]);
        // minimal CSV parse (handles quoted fields with embedded commas/quotes)
        const parse = line => {
          const f = []; let cur = '', q = false;
          for (let i = 0; i < line.length; i++) {
            const ch = line[i];
            if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; }
            else if (ch === '"') q = true;
            else if (ch === ',') { f.push(cur); cur = ''; }
            else cur += ch;
          }
          f.push(cur); return f;
        };
        const hdr = parse(lines[0]);
        resolve(lines.slice(1).map(l => { const c = parse(l); const o = {}; hdr.forEach((h, i) => o[h] = c[i] == null ? '' : c[i]); return o; }));
      });
  });
}
const _cleanTitle = s => String(s || '').replace(/�/g, '').replace(/  +/g, ' ').trim();
function _succName(s) {
  s = String(s || '').replace(/copy[\s_-]*of[\s_-]*/ig, '');   // drop COPY-OF-
  s = s.replace(/[\s_-]+\d+$/, '');                            // drop trailing -1..-8
  s = s.replace(/[_-]+/g, ' ').trim();                         // dashes/underscores -> spaces
  return s.replace(/\s*(wall\s?paper|wall\s?covering)\s*$/i, '').trim();   // drop generic suffix
}
async function resolveSuccessor(mfr, title) {
  const names = [_succName(mfr)];
  const baseTitle = String(title || '').replace(/\s*\|.*$/, '').trim();
  if (baseTitle.length >= 4) {
    // harvest COPY-OF-<x> hints from any row sharing this dead title (often a sibling carries it)
    const sibs = await _pgq(`select coalesce(mfr_sku,'') mfr from shopify_products
      where title ilike '%'||${_pgLit(baseTitle)}||'%' and mfr_sku ~* 'copy' limit 10;`);
    for (const r of sibs) names.push(_succName(r.mfr));
    names.push(baseTitle);
  }
  const seen = new Set(), ordered = [];
  for (let n of names) { n = (n || '').trim(); if (n.length >= 4 && !seen.has(n.toLowerCase())) { seen.add(n.toLowerCase()); ordered.push(n); } }
  for (const name of ordered) {
    const lit = _pgLit(name);
    const rows = await _pgq(`select dw_sku, coalesce(title,'') title, coalesce(handle,'') handle
      from shopify_products
      where lower(status) in ('active','draft') and image_url is not null
        and dw_sku is not null and dw_sku <> '' and title ilike '%'||${lit}||'%'
      order by (title !~* 'memo|sample') desc,
               (lower(title) like lower(${lit})||'%') desc, dw_sku
      limit 1;`);
    if (rows.length) { const r = rows[0]; return { sku: r.dw_sku, title: _cleanTitle(r.title), handle: r.handle }; }
  }
  return null;
}
// Resolve a (usually discontinued/deleted) SKU + its live successor. Returns null if nothing matches.
async function resolveDiscontinued(query) {
  const q = String(query || '').trim();
  if (!q) return null;
  const qbase = q.replace(/-sample$/i, '');   // CHC-216830-SAMPLE typed → base CHC-216830
  const lit = _pgLit(qbase);
  // dash/punct-insensitive base match + de-dashed PREFIX (catches the -SAMPLE suffix); include
  // archived/deleted. Prefer exact dw_sku, then a live one if the base has both, then archived.
  const tgt = await _pgq(`select dw_sku, coalesce(title,'') title, coalesce(status,'') status,
        coalesce(handle,'') handle, coalesce(mfr_sku,'') mfr
      from shopify_products
      where regexp_replace(lower(dw_sku),'[^a-z0-9]','','g') like regexp_replace(lower(${lit}),'[^a-z0-9]','','g')||'%'
         or dw_sku ilike ${lit} or sku ilike ${lit}
         or handle ilike ('%'||${lit}||'%') or mfr_sku ilike ${lit}
      order by (dw_sku ilike ${lit}) desc,
               (lower(status) in ('active','draft')) desc,
               (regexp_replace(lower(dw_sku),'[^a-z0-9]','','g') = regexp_replace(lower(${lit}),'[^a-z0-9]','','g')) desc,
               (lower(status)='archived') desc
      limit 1;`);
  if (!tgt.length) return null;
  const t = tgt[0];
  const status = (t.status || '').trim();
  const discontinued = !['active', 'draft'].includes(status.toLowerCase());
  let successor = null;
  if (discontinued) {
    successor = await resolveSuccessor(t.mfr, t.title);
    if (!successor) {
      // last-resort: nearest active product whose title shares the dead base title
      const base = String(t.title || '').replace(/\s*\|.*$/, '').trim();
      if (base.length >= 4) {
        const rows = await _pgq(`select dw_sku, coalesce(title,'') title, coalesce(handle,'') handle
          from shopify_products where lower(status) in ('active','draft') and image_url is not null
            and dw_sku is not null and dw_sku <> '' and title ilike '%'||${_pgLit(base)}||'%'
          order by (title !~* 'memo|sample') desc, dw_sku limit 1;`);
        if (rows.length) successor = { sku: rows[0].dw_sku, title: _cleanTitle(rows[0].title), handle: rows[0].handle };
      }
    }
  }
  return {
    found: true, sku: t.dw_sku, title: _cleanTitle(t.title),
    status: status.toUpperCase(), discontinued, successor
  };
}

// Query the CLIP visual-search service (front/pattern photo → visually-nearest catalog products).
function visualSearch(b64, k) {
  return new Promise(resolve => {
    let target; try { target = new URL(VSEARCH_URL + '/search'); } catch (e) { return resolve([]); }
    const lib = target.protocol === 'https:' ? https : http;
    const payload = JSON.stringify({ image: b64, k: k || 8 });
    const rq = lib.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, timeout: 20000 },
      res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve((JSON.parse(d).results) || []); } catch (e) { resolve([]); } }); });
    rq.on('error', () => resolve([])); rq.on('timeout', () => { rq.destroy(); resolve([]); });
    rq.write(payload); rq.end();
  });
}

// Listen-time failure (e.g. EADDRINUSE from a second accidental launch): log + exit(1) so the
// supervisor (pm2/launchd) applies its own backoff, instead of the uncaughtException handler
// swallowing it into a limping process that never actually bound the port. (TK-11962)
server.on('error', (e) => {
  if (e && e.code === 'EADDRINUSE') { console.error(`[server.on error] port ${PORT} already in use — another instance is running; exiting for supervisor backoff`); process.exit(1); }
  console.error('[server.on error]', (e && e.stack) || e);
});
server.listen(PORT, '0.0.0.0', () => {
  console.log(`DW Photo Capture on http://0.0.0.0:${PORT}  (Shopify push: ${TEST_MODE ? 'STUBBED (--test)' : TOKEN ? 'ON' : 'OFF — no token'}, sheet GRS: ${SHEET.length})`);
  rebuildIndex();              // sheet-only items searchable immediately
  if (TEST_MODE) return;       // TK-12228: no catalog fetch / multi-hundred-MB cache write in test mode
  buildCatalog();              // load the WHOLE dw_unified catalog from the all-dw feed
  // keep the browsable catalog fresh (feed refreshes its own snapshot every 10 min)
  setInterval(() => buildCatalog().catch(() => {}), 15 * 60 * 1000);
});