← back to Dw Photo Capture

server.js

2817 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');

// 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';
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()) 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 = path.join(ROOT, 'data');
const 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 || '';
if (!TOKEN) {
  try {
    for (const l of fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8').split('\n'))
      if (l.startsWith('SHOPIFY_ADMIN_TOKEN=')) { TOKEN = l.split('=').slice(1).join('=').trim(); break; }
  } catch (e) {}
}

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 */ }
}
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 (!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,}/ },
];
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 (Ollama qwen2.5vl, $0) ────────────────────
// 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.
const OLLAMA_VISION_MODEL = process.env.OLLAMA_VISION_MODEL || 'qwen2.5vl:7b';   // reuses OLLAMA_URL declared with ollamaAsk
// Allow-listed vision models a request may pick. 'fast' => moondream (~3-5s, lower accuracy);
// default qwen2.5vl is more accurate (~15-20s). Anything off-list is ignored (no arbitrary pulls).
const VISION_MODELS = { 'qwen2.5vl:7b': 'qwen2.5vl:7b', 'moondream:latest': 'moondream:latest',
  'moondream': 'moondream:latest', 'fast': 'moondream:latest', 'accurate': 'qwen2.5vl:7b', 'llava:13b': 'llava:13b' };
function pickVisionModel(req) { return VISION_MODELS[String(req || '').toLowerCase().trim()] || OLLAMA_VISION_MODEL; }
function ollamaVision(b64, prompt, timeoutMs, model) {
  return new Promise(resolve => {
    let target; try { target = new URL('/api/generate', OLLAMA_URL); } catch (e) { return resolve({ error: 'bad OLLAMA_URL' }); }
    const lib = target.protocol === 'https:' ? https : http;
    const payload = JSON.stringify({ model: model || OLLAMA_VISION_MODEL, prompt, images: [b64],
      stream: false, format: 'json', options: { temperature: 0 } });
    // default 35s is fine once the model is warm; callers expecting a possible COLD model
    // load (first call after idle ≈ 50s here) pass a larger budget so they don't false-timeout.
    const r = lib.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: timeoutMs || 35000 },
      res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } 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();
  });
}

// ── Pluggable vision engines: macvision (Apple Vision + local Ollama) · gemini · gcv ──
// Steve 2026-07-06: so the scanner runs on Kamatera (Linux), where the macOS `bin/ocr`
// Vision binary and a local Ollama don'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'));
const DEFAULT_ENGINE = (process.env.OCR_ENGINE || (HAS_MACVISION ? 'macvision' : 'gemini')).toLowerCase();
const ALL_ENGINES = ['macvision', 'gemini', 'gcv'];
// Rough per-scan cost so the caller always sees the $ (Steve's "always show costs" rule).
const ENGINE_COST_USD = { macvision: 0, gemini: 0.0006, gcv: 0.0015 };
function engineAvailable(e) {
  if (e === 'macvision') return HAS_MACVISION;
  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 ≤1600px JPEG so OCR
// engines (GCV needs JPEG/PNG, not HEIC) + storage get a sane image. Best-effort — original on failure.
function normalizeImage(buf) {
  return new Promise(resolve => {
    let out = [];
    const cp = execFile('/usr/bin/convert', ['-', '-auto-orient', '-resize', '1600x1600>', '-quality', '85', '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 ≤1600px 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. macvision => local Ollama;
// gemini/gcv => Gemini (GCV is OCR-only, so identify always routes to Gemini there).
// Returns { response:<json-text>, model, error } to match the Ollama shape callers parse.
function visionJSON(engine, b64, prompt, opts) {
  opts = opts || {};
  if (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-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 });

  // ── 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));
      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) 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);
  }

  // ── 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 (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
      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).
      const engine = resolveEngines(p.engine)[0] || DEFAULT_ENGINE;
      const useModel = engine === 'macvision' ? 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 (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
      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>"}';
      const engine = resolveEngines(p.engine)[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 }; }
      }
      return send(res, 200, { ok: !r.error, model: (engine === 'macvision' ? OLLAMA_VISION_MODEL : GEMINI_VISION_MODEL), engine,
        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</a></p>`);
  }

  if (u.pathname === '/' || 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 (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
      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;
      send(res, 200, { ok: !r.error, fields: a, vendor_matched: vendorMatch, 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;
  }

  // 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 { send(res, 200, await createNewItem(p, b64, p.commit !== true)); }
      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/')) {
    const f = path.join(PHOTOS, path.basename(u.pathname));
    if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg' }); return res.end(fs.readFileSync(f)); }
    return send(res, 404, { err: 'not found' });
  }

  // App icons + PWA manifest — make "Add to Home Screen" a real app (clean icon, fullscreen).
  if (u.pathname === '/icon-180.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: 'Mobile DW SKU Photos', short_name: 'DW SKU Photos', start_url: '/?app=1', display: 'standalone',
      background_color: '#0f0e0c', theme_color: '#0f0e0c',
      icons: [{ src: '/icon-180.png', sizes: '180x180', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any 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 (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
      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 || !p.dataUrl) return send(res, 400, { err: 'product_id + dataUrl 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 safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
      const fname = `${safe}-${Date.now()}.jpg`;
      fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64'));
      const entry = progress[dw_sku] || {};
      entry.done = true; entry.skipped = false; entry.photo = `/photos/${fname}`; 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();
      return send(res, 200, { ok: true, dw_sku, photo: entry.photo, 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
      let added = 0; const errors = []; let lastPhoto = null;
      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 safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
        const fname = `${safe}-${Date.now()}-${i}.jpg`;
        try { fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64')); lastPhoto = `/photos/${fname}`; } catch (e) {}
        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();
      return send(res, 200, { ok: added > 0, added, total: urls.length, errors, 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')) };
  https.createServer(tls, appHandler).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);
    });
  });
}

// Create a NEW item (arbitrary vendor) as a Shopify DRAFT + a dw_unified staging row. Draft-only,
// never auto-published (going live is a separate gated step). dryRun (default) returns a 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' };
  // 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));
  const dwsku = String(p.dw_sku || '').trim() || (String(p.vid || vendor).slice(0, 4).toUpperCase().replace(/[^A-Z0-9]/g, '') + mfr.replace(/[^A-Za-z0-9]/g, ''));
  const name = String(p.name || '').trim(); const color = String(p.color || '').trim();
  const price = p.price && +p.price > 0 ? String(p.price) : null;
  const title = [name || mfr, color, '|', vendor].filter(Boolean).join(' ').replace(' | ', ' | ');
  const preview = { title, dw_sku: dwsku, mfr, vendor, vid: p.vid || null, color, price, status: 'draft', photos: Array.isArray(p._photos64) ? p._photos64.length : (b64 ? 1 : 0), duplicate_of: dup ? (dup.dw_sku || dup.product_id) : null };
  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 };
  try {
    const mf = (ns, key, val) => val ? { namespace: ns, key, value: String(val), type: '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 material = (p.material || '').trim() || 'Wallcovering';
    const payload = { product: {
      title, vendor, product_type: material, status: 'draft', tags: ['new-from-scan', 'display_variant', 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', 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).
    // `specs` jsonb keeps the FULL captured spec set — every field off any spec sheet/sample, nothing dropped.
    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 || '' };
    // $$-dollar-quoting: a stray "$$" in ANY value would break the quote → strip it from EVERY
    // interpolated string (not just specs) so a vendor/mfr like "Foo $$ Bar" can't malform the SQL.
    const esc = s => String(s == null ? '' : s).replace(/\$\$/g, '$');
    const specsJson = esc(JSON.stringify(specsObj));
    const stageSQL = `insert into new_items_staging (dw_sku, mfr_sku, vendor, vid, pattern_name, color, price, specs, shopify_product_id, created_via)
      values ($$${esc(dwsku)}$$,$$${esc(mfr)}$$,$$${esc(vendor)}$$,$$${esc(p.vid || '')}$$,$$${esc(name)}$$,$$${esc(color)}$$,${price || 'NULL'},$$${specsJson}$$::jsonb,${pid || 'NULL'},$$scan$$) on conflict do nothing`;
    execFile(PSQL, ['-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 }, () => {});
    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', 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 = [];
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 { fs.writeFileSync(CATALOG_CACHE, JSON.stringify(feed)); } 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
  try {
    const feed = JSON.parse(fs.readFileSync(CATALOG_CACHE, 'utf8'));
    if (feed && Array.isArray(feed.rows) && feed.rows.length) {
      CATALOG = feed.rows.map(feedRowToItem);
      console.log(`catalog indexed: ${CATALOG.length} products from disk cache (feed down)`);
      return rebuildIndex();
    }
  } catch (e) { /* no cache yet */ }
  // 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 Ollama Q&A (free, on-LAN) ──────────────────────────────────────────
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen2.5:latest';
function ollamaAsk(prompt) {
  return new Promise((resolve, reject) => {
    const ou = new URL(OLLAMA_URL + '/api/generate');
    const data = JSON.stringify({ model: OLLAMA_MODEL, prompt, stream: false, options: { num_predict: 160, temperature: 0.2 } });
    const lib = ou.protocol === 'https:' ? https : http;
    const r = lib.request({ hostname: ou.hostname, port: ou.port, path: ou.pathname, method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } }, resp => {
      let d = ''; resp.on('data', c => d += c); resp.on('end', () => { try { resolve((JSON.parse(d).response) || ''); } catch (e) { reject(e); } });
    });
    r.setTimeout(30000, () => r.destroy(new Error('ollama timeout')));
    r.on('error', reject); r.write(data); r.end();
  });
}
// 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 = s => "'" + String(s == null ? '' : s).replace(/'/g, "''") + "'";
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();
  });
}

server.listen(PORT, '0.0.0.0', () => {
  console.log(`DW Photo Capture on http://0.0.0.0:${PORT}  (Shopify push: ${TOKEN ? 'ON' : 'OFF — no token'}, sheet GRS: ${SHEET.length})`);
  rebuildIndex();              // sheet-only items searchable immediately
  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);
});