← back to Japan Enrich

viewer-local/server.js

646 lines

#!/usr/bin/env node
/**
 * Staging viewer for the Sangetsu + Lilycolor onboarding catalogs (OFFLINE / read-only).
 * Reads the two staging JSONL files, flattens them to a uniform SKU row, and serves a
 * paginated /api/skus endpoint behind a sort + density + infinite-scroll grid.
 *
 * HARD: read-only. No Shopify, no dw_unified, no publish. Pure local preview of staged data.
 *   node viewer/server.js [port]   (default 9931)
 */
const http = require('http');
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');

const PORT = parseInt(process.argv[2] || '9931', 10);
const DIR = path.join(__dirname, '..', 'staging');
const LILY = path.join(DIR, 'lilycolor-unified-staging.jsonl');   // trade pricebook + specs + price + image manifest
const SANG = path.join(DIR, 'sangetsu-staging.jsonl');
const GREEN = path.join(DIR, 'greenland-staging.jsonl');  // china. scope: Greenland vendor (pattern→colorways)
const PUBLIC = path.join(__dirname, 'public');
const HENRY_IMG = '/Volumes/Henry/dw-lily-images';   // selectively-fetched pattern swatches
const GREEN_IMG = path.join(__dirname, '..', 'images', 'greenland');  // downscaled colorway swatches (enrich_greenland.py)
const SANG_IMG = path.join(__dirname, '..', 'images', 'sangetsu-sw');  // downscaled colorway swatches (make_sangetsu_swatches.py)

const readJsonl = (f) => (fs.existsSync(f)
  ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
  : []);

// ── import queue (local, append-only, REVERSIBLE) ───────────────────────────────
// The "⤓ Import" button enqueues a scoped SKU here. This records INTENT only —
// promotion into canonical dw_unified / Shopify is a SEPARATE, Steve-gated step
// (db/promote-import-queue.js). Nothing customer-facing happens from the viewer.
const IMPORT_QUEUE = path.join(__dirname, '..', 'staging', 'import-queue.jsonl');
const readQueue = () => readJsonl(IMPORT_QUEUE);
const writeQueue = (arr) => fs.writeFileSync(IMPORT_QUEUE, arr.map((r) => JSON.stringify(r)).join('\n') + (arr.length ? '\n' : ''));
function readBody(req) {
  return new Promise((resolve) => {
    let b = ''; req.on('data', (c) => { b += c; if (b.length > 8e6) req.destroy(); });
    req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } });
    req.on('error', () => resolve({}));
  });
}

// --- enrichment overlay (hex / color_family / style / material) ----------------
// The local-hybrid enricher ($0, Pillow + qwen2.5vl) emits full staging rows + an
// `enrich` block keyed by mfr_sku. Read whichever enriched file(s) exist (later wins).
// Sangetsu has no image manifests yet, so it stays unenriched and degrades gracefully.
const normSku = (s) => String(s || '').replace(/[^A-Za-z0-9]/g, '').toUpperCase();
// Locally-extracted Lilycolor colorway (C) swatches: images/all_c/LIS-42001_..._C_01.jpg
// Index by normalized SKU (filename token before first "_") so cards have real swatches
// even when preview_images is empty and the Henry volume isn't mounted.
const ALLC_DIR = path.join(__dirname, '..', 'images', 'all_c');
const allcIndex = {};
try {
  if (fs.existsSync(ALLC_DIR)) for (const fn of fs.readdirSync(ALLC_DIR)) {
    if (/\.(jpe?g|png)$/i.test(fn)) { const k = normSku(fn.split('_')[0]); if (!allcIndex[k]) allcIndex[k] = path.join(ALLC_DIR, fn); }
  }
} catch {}
// Greenland downscaled swatches are saved as images/greenland/<normSku>.jpg by
// enrich_greenland.py — a Set of which SKUs have a local swatch, so cards can serve
// /img/greenland/<sku> (self-contained) and fall back to the remote CDN url otherwise.
let greenSwatches = new Set();
const loadGreenSwatches = () => {
  const s = new Set();
  try { if (fs.existsSync(GREEN_IMG)) for (const fn of fs.readdirSync(GREEN_IMG)) if (/\.jpe?g$/i.test(fn)) s.add(fn.replace(/\.jpe?g$/i, '')); } catch {}
  greenSwatches = s;
};
loadGreenSwatches();
// Sangetsu downscaled swatches (images/sangetsu-sw/<normSku>.jpg) — same self-contained
// pattern as Greenland so japan. cards serve /img/sangetsu/<sku> instead of the vendor CDN.
let sangSwatches = new Set();
const loadSangSwatches = () => {
  const s = new Set();
  try { if (fs.existsSync(SANG_IMG)) for (const fn of fs.readdirSync(SANG_IMG)) if (/\.jpe?g$/i.test(fn)) s.add(fn.replace(/\.jpe?g$/i, '')); } catch {}
  sangSwatches = s;
};
loadSangSwatches();
// REAL per-pattern Sangetsu distributor scraped from the .psg-single-brands-main brand
// link (Koroseal / Eijffinger / J.Josephson / Omexco / …), keyed by source_url. Replaces
// the old host-derived 'Goodrich (Thailand)'. Blank when a pattern isn't scraped yet.
const SANG_BRANDS_FILE = path.join(DIR, 'sangetsu-brands.json');
let SANG_BRANDS = {};
const loadSangBrands = () => { try { SANG_BRANDS = JSON.parse(fs.readFileSync(SANG_BRANDS_FILE, 'utf8')); } catch { SANG_BRANDS = {}; } };
loadSangBrands();
// ── visual match: distributor catalog colors (computed locally, compute_distributor_colors.py) ──
// distributor label → dw_unified vendor_code (only these have a DW catalog to match against)
const DIST_VENDOR = {
  'Koroseal': 'koroseal', 'Cole & Son': 'cole_son', 'Harlequin': 'harlequin',
  'Versa Design Surfaces': 'versa_ds', 'Carlisle & Co': 'carlisle', 'Versace': 'versace',
  'Colefax & Fowler': 'colefax_fowler', 'Nicolette Mayer': 'nicolette_mayer',
  // sub-vendors that also have their own dw_unified catalog (Sanderson Design Group + luxe brands)
  'Morris & Co': 'william_morris', 'Missoni': 'missoni', 'Zoffany': 'zoffany',
  'Armani Casa': 'armani_casa', 'Vahallan': 'vahallan',
  // (Casadeco/Sanderson/Scion/Omexco/Eijffinger/Texam/Texdecor/Greenland/P3TEC/Altagamma/Jwall/
  //  La Scala/Jannelli have NO dw catalog → visual match via reverse-image (Lens) + source page)
};
const DIST_COLORS_FILE = path.join(DIR, 'distributor-colors.jsonl');
let DIST_COLORS = {};   // vendor_code -> [ {sku,name,color_name,image_url,product_url,hex,palette,family,hue} ]
const loadDistColors = () => {
  const m = {};
  for (const r of readJsonl(DIST_COLORS_FILE)) { if (r && r.vendor) (m[r.vendor] = m[r.vendor] || []).push(r); }
  DIST_COLORS = m;
};
loadDistColors();
const hexRGB = (h) => { h = String(h || '').replace('#', ''); return [0, 2, 4].map((i) => parseInt(h.substr(i, 2), 16) || 0); };
const rgbDist = (a, b) => { const x = hexRGB(a), y = hexRGB(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
// palette-weighted distance (dominant 50% / 2nd 30% / 3rd 20%) — a single ground color isn't
// enough for patterned wallcovering, so weight the top-3 swatches (contrarian fix).
const WT = [0.5, 0.3, 0.2];
const paletteDist = (pa, pb) => {
  pa = pa && pa.length ? pa : []; pb = pb && pb.length ? pb : [];
  if (!pa.length || !pb.length) return 999;
  let d = 0; for (let i = 0; i < 3; i++) d += WT[i] * rgbDist(pa[Math.min(i, pa.length - 1)], pb[Math.min(i, pb.length - 1)]);
  return d;
};
const STOP = new Set(['the', 'and', 'for', 'red', 'tan', 'new', 'old', 'big', 'wall', 'wallpaper', 'wallcovering', 'collection', 'vol', 'grey', 'gray', 'blue']);
const wordset = (s) => new Set(String(s || '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter((w) => w.length > 3 && !STOP.has(w)));
// distributor → subdomain slug map, so <slug>.designerwallcoverings.com is a per-distributor
// microsite (e.g. koroseal. / eijffinger. / j-josephson.). Built from the scraped brand map.
let DIST_SLUGS = {};
const buildDistSlugs = () => {
  const m = { lilycolor: 'Lilycolor (direct)' };
  for (const v of Object.values(SANG_BRANDS)) if (v && v.slug && v.distributor) m[v.slug] = v.distributor;
  DIST_SLUGS = m;
};
buildDistSlugs();
const ENRICH_FILES = [
  process.env.ENRICH_FILE,                                          // explicit override
  path.join(DIR, 'lilycolor-enriched.jsonl'),                       // post-deploy: beside staging
  path.join(__dirname, '..', 'out', 'lilycolor-enriched.jsonl'),    // live local run
  path.join(__dirname, '..', 'out', 'pilot-enriched.jsonl'),        // local pilot / full backfill
  path.join(DIR, 'sangetsu-enriched.jsonl'),                        // post-deploy: beside staging
  path.join(__dirname, '..', 'out', 'sangetsu-enriched.jsonl'),     // Sangetsu per-colorway enrich (keyed by colorway SKU)
  path.join(DIR, 'greenland-enriched.jsonl'),                       // post-deploy: beside staging
  path.join(__dirname, '..', 'out', 'greenland-enriched.jsonl'),    // Greenland per-colorway hex/family (keyed by colorway SKU)
].filter(Boolean);
function loadEnrich() {
  const idx = {};
  for (const f of ENRICH_FILES) {
    for (const r of readJsonl(f)) {
      if (r && r.enrich && r.mfr_sku) idx[normSku(r.mfr_sku)] = r.enrich;
    }
  }
  return idx;
}
let ENRICH = loadEnrich();

// 12 color families (fixed order for the Color sort + swatch nav), with a representative dot hex.
const FAMILY_ORDER = ['Red', 'Orange/Brown', 'Yellow', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Neutral/Beige', 'Black', 'Grey', 'White'];

// ── derived facet helpers (the "and more" dimensions) ───────────────────────────
// Price bands on the Japan LIST price (¥/m) — fixed order so the chips read low→high.
// NB: labels MUST stay comma-free — facet multi-select is CSV-encoded in the query string.
const PRICE_BANDS = [
  { key: '≤¥1k', test: (y) => y <= 1000 },
  { key: '¥1k–2k', test: (y) => y > 1000 && y <= 2000 },
  { key: '¥2k–4k', test: (y) => y > 2000 && y <= 4000 },
  { key: '¥4k–8k', test: (y) => y > 4000 && y <= 8000 },
  { key: '¥8k+', test: (y) => y > 8000 },
];
const PRICE_ORDER = PRICE_BANDS.map((b) => b.key);
const priceBand = (yen) => { if (!yen) return null; const b = PRICE_BANDS.find((x) => x.test(yen)); return b ? b.key : null; };
// Fire/flammability class — bucket the Japanese code (不燃 / 準不燃 / 防火) or Sangetsu Type II/III.
const fireClass = (s) => {
  if (!s) return null;
  if (/^不燃/.test(s)) return 'Non-combustible 不燃';
  if (/^準不燃/.test(s)) return 'Semi-noncomb 準不燃';
  if (/^防火/.test(s)) return 'Fire-retardant 防火';
  if (/^type/i.test(s)) return s.replace(/^type/i, 'Type').trim();
  return s.split(/\s+/)[0];
};
const collectionName = (c) => (c === 'ImportSelection' ? 'Import Selection' : c) || null;

// ── distributor / purchase channel ──────────────────────────────────────────────
// The MILL (source: Sangetsu / Lilycolor / Greenland) is NOT always who we order from.
// Sangetsu is a mill whose catalog is only buyable through its regional DISTRIBUTOR,
// Goodrich — the site we actually scraped (sangetsu-goodrich.co.th). So each row also
// carries a buy-from channel, derived from source + source_url host, separate from the
// brand badge. Lilycolor & Greenland sell direct (channel == mill) → dist_direct:true.
const DIST_BY_HOST = [
  [/sangetsu-goodrich\.co\.th$/i, 'Goodrich (Thailand)'],
  [/sangetsu-goodrich\.vn$/i, 'Goodrich (Vietnam)'],
  [/goodrichglobal\.com$/i, 'Goodrich (Singapore)'],
];
function distInfo(source, url) {
  let host = '';
  try { host = new URL(url).hostname.replace(/^www\./, ''); } catch {}
  for (const [re, label] of DIST_BY_HOST) if (re.test(host)) return { distributor: label, dist_direct: false };
  if (source === 'sangetsu') return { distributor: 'Goodrich (Thailand)', dist_direct: false };   // sangetsu w/o a recognized host: same label as .co.th so the facet never splits
  if (source === 'lilycolor') return { distributor: 'Lilycolor (direct)', dist_direct: true };
  if (source === 'greenland') return { distributor: 'Greenland (direct)', dist_direct: true };
  return { distributor: null, dist_direct: false };
}

// Flatten both vendors to one uniform SKU row: {source, sku, title, width, image, url, tags[]}
function buildRows() {
  const rows = [];
  // Lilycolor: unified trade row — one SKU, full specs + price + image manifest
  const lilyRows = readJsonl(LILY);
  // colorway grouping: one pattern (sku_range_raw) holds N colorway SKUs.
  // Build pattern → [colorway SKUs] so each card can show its siblings as vendor-style chips.
  const patterns = new Map();
  for (const r of lilyRows) {
    const key = (r.sku_range_raw || r.mfr_sku || '').trim();
    if (!key) continue;
    if (!patterns.has(key)) patterns.set(key, []);
    patterns.get(key).push(r.mfr_sku);
  }
  // siblings = the OTHER colorways in this pattern, each tinted by its own enriched hex (grey until enriched)
  const siblingChip = (s) => { const se = ENRICH[normSku(s)] || null; return { sku: s, hex: (se && se.hex && se.hex[0]) ? se.hex[0].hex : null, family: se ? se.color_family : null }; };
  for (const r of lilyRows) {
    const e = ENRICH[normSku(r.mfr_sku)] || null;
    const key = (r.sku_range_raw || r.mfr_sku || '').trim();
    const group = patterns.get(key) || [r.mfr_sku];
    const siblings = group.filter((s) => s !== r.mfr_sku).map(siblingChip);
    // prefer the locally-fetched clean swatch on Henry; else shop-CDN swatch-first preview
    const imgVal = ((allcIndex[normSku(r.mfr_sku)] || fs.existsSync(path.join(HENRY_IMG, `${r.mfr_sku}.jpg`))) ? `/img/lily/${r.mfr_sku}` : (r.preview_images && r.preview_images[0])) || null;
    rows.push({
      source: 'lilycolor',
      sku: r.mfr_sku,
      prefix: r.mfr_prefix || null,
      title: r.title_en || r.title_ja || r.mfr_sku,       // draft EN title (enrich_lilycolor_titles.py) → JP → SKU
      catalog: r.source_catalog || null,                  // WILL / LIGHT / V-wall / MATERIALS / Import Selection
      collection: collectionName(r.source_catalog),       // facet: vendor catalog/book
      width: r.width || null,                             // facet: roll width
      composition: (r.functions && r.functions.length) ? r.functions.join(', ') : null,
      functions: r.functions || [],                       // facet (multi): performance functions
      fire: (r.fire_ratings && r.fire_ratings[0]) || null,
      fire_class: fireClass((r.fire_ratings && r.fire_ratings[0]) || null), // facet: fire class
      price_yen: r.list_price_yen || null,                 // Japan LIST price (¥), not confirmed net cost
      price_band: priceBand(r.list_price_yen || null),     // facet: ¥ price band
      repeat: (r.repeat_tate_cm && r.repeat_yoko_cm) ? `${r.repeat_tate_cm}×${r.repeat_yoko_cm}cm` : null,
      image: imgVal,
      image_state: imgVal ? 'Has swatch' : ((r.image_count || 0) ? 'Archived only' : 'No image'), // facet: swatch availability
      image_count: r.image_count || 0,
      image_kinds: r.image_kinds || [],
      url: r.shop_handle ? `https://shop.lilycolor.co.jp/products/${r.shop_handle}` : null,
      ...distInfo('lilycolor', null),                     // facet/chip: buy-from channel (Lilycolor sells direct)
      is_sample: false,
      // colorway family — this card + its sibling colorways (vendor-style "other colors of this pattern")
      colorway_count: group.length,
      siblings,
      // enrichment overlay (null/false when this SKU hasn't been image-enriched yet)
      enriched: !!e,
      color_family: e ? e.color_family : null,
      style: e ? e.style : null,
      material: e ? e.material : null,
      hex: (e && e.hex && e.hex[0]) ? e.hex[0].hex : null,
      palette: (e && e.hex) ? e.hex.slice(0, 5).map((x) => x.hex) : [],
    });
  }
  // Sangetsu: one PATTERN explodes to N colorway SKUs, each with its own full-res image
  for (const r of readJsonl(SANG)) {
    const imgs = r.sku_images || {};
    const cw = r.colorway_skus || [];
    for (const sku of cw) {
      // per-colorway enrichment (hex/family per colorway; style/material fanned from the pattern-level vision pass)
      const e = ENRICH[normSku(sku)] || null;
      const siblings = cw.filter((s) => s !== sku).map(siblingChip);
      // prefer the self-contained local swatch; fall back to the vendor CDN url
      const sImg = (sangSwatches.has(normSku(sku)) ? `/img/sangetsu/${sku}` : (imgs[sku] || null)) || null;
      rows.push({
        source: 'sangetsu',
        sku,
        prefix: r.mfr_prefix || null,
        title: r.pattern || sku,
        collection: null,                                 // Sangetsu has no vendor-book facet yet
        width: (r.spec && r.spec.width) || null,          // facet: roll width (sparse for Sangetsu)
        composition: (r.spec && r.spec.is_grasscloth) ? 'Grasscloth' : null,
        functions: (r.spec && r.spec.is_metallic) ? ['metallic'] : [], // facet (multi): scant for Sangetsu
        fire: (r.spec && r.spec.type) || null,
        fire_class: fireClass((r.spec && r.spec.type) || null), // facet: fire class (Type II/III)
        price_yen: null,
        price_band: null,                                 // no confirmed Sangetsu price yet
        image: sImg,
        image_state: sImg ? 'Has swatch' : 'No image',    // facet: swatch availability
        url: r.source_url || null,
        // real distributor from the scraped brand map (blank until scraped) — NOT host-derived
        distributor: (SANG_BRANDS[r.source_url] && SANG_BRANDS[r.source_url].distributor) || null,
        dist_direct: false,
        distributor_slug: (SANG_BRANDS[r.source_url] && SANG_BRANDS[r.source_url].slug) || null,
        is_sample: false,
        colorway_count: cw.length,
        siblings,
        // enrichment overlay (null/false until this colorway's image is fetched + enriched)
        enriched: !!e,
        color_family: e ? e.color_family : null,
        style: e ? e.style : null,
        material: e ? e.material : null,
        hex: (e && e.hex && e.hex[0]) ? e.hex[0].hex : null,
        palette: (e && e.hex) ? e.hex.slice(0, 5).map((x) => x.hex) : [],
      });
    }
  }
  // Greenland (china. scope): one PATTERN → N colorway SKUs (model), each with its own
  // remote thumbnail. Cover-image-only patterns (no colorway rows in the vendor feed)
  // fall back to a single pattern card so all 880 patterns render, not just the ~221
  // that have per-colorway breakdowns. No enrichment/price yet — degrades gracefully.
  for (const r of readJsonl(GREEN)) {
    const skus = r.colorway_skus || [];
    const imgs = r.sku_images || {};
    const colors = r.sku_color || {};
    const emit = skus.length ? skus : [r.product_code || `GL-${r.product_id}`];
    for (const sku of emit) {
      const isCw = skus.length > 0;
      const e = ENRICH[normSku(sku)] || null;             // per-colorway hex/family once enriched
      const siblings = isCw ? skus.filter((s) => s !== sku).map((s) => siblingChip(s)) : [];
      rows.push({
        source: 'greenland',
        sku,
        prefix: r.mfr_prefix || null,
        title: r.pattern || sku,
        catalog: r.category || null,                       // top ("Wallcovering")
        collection: r.material || null,                    // facet: material book (Sisal/Cork/…)
        width: null,
        composition: r.material || null,
        functions: [],
        fire: null,
        fire_class: null,
        price_yen: null,
        price_band: null,
        image: (greenSwatches.has(normSku(sku)) ? `/img/greenland/${sku}` : ((isCw ? imgs[sku] : r.cover_img) || r.cover_img)) || null,
        image_state: ((greenSwatches.has(normSku(sku))) || (isCw ? imgs[sku] : r.cover_img)) ? 'Has swatch' : 'No image',
        url: r.source_url || null,
        ...distInfo('greenland', r.source_url),            // facet/chip: buy-from channel (Greenland direct)
        is_sample: false,
        colorway_count: isCw ? skus.length : 1,
        siblings,
        // material comes straight from the vendor category tree (reliable); hex/color_family
        // land per-colorway once enrich_greenland.py (Pillow, $0 local) writes its overlay.
        enriched: !!e,
        color_family: e ? e.color_family : null,
        style: e ? e.style : null,
        material: r.material || (e && e.material) || null,   // fresh staging (clean fiber) wins over enriched copy
        color_name: colors[sku] || null,                   // per-colorway vendor color name
        hex: (e && e.hex && e.hex[0]) ? e.hex[0].hex : null,
        palette: (e && e.hex) ? e.hex.slice(0, 5).map((x) => x.hex) : [],
      });
    }
  }
  return rows;
}

let ROWS = buildRows();
const reload = () => { loadGreenSwatches(); loadSangSwatches(); loadSangBrands(); buildDistSlugs(); loadDistColors(); ENRICH = loadEnrich(); ROWS = buildRows(); };
try { if (fs.existsSync(DIST_COLORS_FILE)) fs.watchFile(DIST_COLORS_FILE, { interval: 10000 }, loadDistColors); } catch {}
try { if (fs.existsSync(SANG_BRANDS_FILE)) fs.watchFile(SANG_BRANDS_FILE, { interval: 5000 }, reload); } catch {}
fs.watchFile(SANG, { interval: 4000 }, reload);   // pick up Sangetsu staging as it grows
fs.watchFile(LILY, { interval: 8000 }, reload);
try { if (fs.existsSync(GREEN)) fs.watchFile(GREEN, { interval: 8000 }, reload); } catch {}
// pick up enriched rows live as the local-hybrid job writes them
for (const f of ENRICH_FILES) { try { if (fs.existsSync(f)) fs.watchFile(f, { interval: 8000 }, reload); } catch {} }
let lastImgCount = -1;                              // re-resolve primary images as swatches land on Henry
setInterval(() => {
  try { const n = fs.existsSync(HENRY_IMG) ? fs.readdirSync(HENRY_IMG).length : 0; if (n !== lastImgCount) { lastImgCount = n; reload(); } } catch {}
}, 6000);

const sorters = {
  newest: null, // server natural order
  sku: (a, b) => String(a.sku).localeCompare(String(b.sku)),
  title: (a, b) => String(a.title).localeCompare(String(b.title)),
  width: (a, b) => (parseFloat(a.width) || 0) - (parseFloat(b.width) || 0),
  price: (a, b) => (a.price_yen || 1e9) - (b.price_yen || 1e9),
  images: (a, b) => (b.image_count || 0) - (a.image_count || 0),
  // group by color family (fixed wheel order), then SKU — unenriched sink to the end
  color: (a, b) => { const r = (x) => { const i = FAMILY_ORDER.indexOf(x.color_family); return i < 0 ? 99 : i; }; return r(a) - r(b) || String(a.sku).localeCompare(String(b.sku)); },
  style: (a, b) => String(a.style || '~~').localeCompare(String(b.style || '~~')) || String(a.sku).localeCompare(String(b.sku)),
  material: (a, b) => String(a.material || '~~').localeCompare(String(b.material || '~~')) || String(a.sku).localeCompare(String(b.sku)),
  distributor: (a, b) => String(a.distributor || '~~').localeCompare(String(b.distributor || '~~')) || String(a.sku).localeCompare(String(b.sku)),
};

// shared filter — opts.skip names a facet dimension to EXCLUDE (for drill-down facet counts)
function parseFilters(u) {
  const csv = (k) => new Set((u.searchParams.get(k) || '').split(',').map((s) => s.trim()).filter(Boolean));
  return {
    source: u.searchParams.get('source') || 'all',
    q: (u.searchParams.get('q') || '').toLowerCase(),
    colors: csv('colors'), styles: csv('styles'), materials: csv('materials'),
    collections: csv('collections'), widths: csv('widths'), fires: csv('fires'),
    funcs: csv('funcs'), prices: csv('prices'), images: csv('images'), distributors: csv('distributors'),
    enriched: u.searchParams.get('enriched') || 'all',
  };
}
function applyFilters(rows, f, opts = {}) {
  let out = rows;
  if (f.source && f.source !== 'all') out = out.filter((r) => r.source === f.source);
  if (f.q) out = out.filter((r) => (r.sku + ' ' + r.title).toLowerCase().includes(f.q));
  if (opts.skip !== 'color' && f.colors.size) out = out.filter((r) => r.color_family && f.colors.has(r.color_family));
  if (opts.skip !== 'style' && f.styles.size) out = out.filter((r) => r.style && f.styles.has(r.style));
  if (opts.skip !== 'material' && f.materials.size) out = out.filter((r) => r.material && f.materials.has(r.material));
  if (opts.skip !== 'collection' && f.collections.size) out = out.filter((r) => r.collection && f.collections.has(r.collection));
  if (opts.skip !== 'width' && f.widths.size) out = out.filter((r) => r.width && f.widths.has(r.width));
  if (opts.skip !== 'fire' && f.fires.size) out = out.filter((r) => r.fire_class && f.fires.has(r.fire_class));
  if (opts.skip !== 'funcs' && f.funcs.size) out = out.filter((r) => r.functions && r.functions.some((x) => f.funcs.has(x)));
  if (opts.skip !== 'price' && f.prices.size) out = out.filter((r) => r.price_band && f.prices.has(r.price_band));
  if (opts.skip !== 'image' && f.images.size) out = out.filter((r) => r.image_state && f.images.has(r.image_state));
  if (opts.skip !== 'distributor' && f.distributors.size) out = out.filter((r) => r.distributor && f.distributors.has(r.distributor));
  if (opts.skip !== 'enriched' && f.enriched === 'enriched') out = out.filter((r) => r.enriched);
  if (opts.skip !== 'enriched' && f.enriched === 'unenriched') out = out.filter((r) => !r.enriched);
  return out;
}

// Admin Basic Auth — enforced only when REQUIRE_AUTH is set (on Kamatera);
// local 9931 stays open. Creds default admin/DW2024!, override via BASIC_AUTH="user:pass".
const REQUIRE_AUTH = !!process.env.REQUIRE_AUTH;
const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
function authed(req) {
  const h = req.headers.authorization || '';
  if (!h.startsWith('Basic ')) return false;
  const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
  return u === AUTH_USER && p === AUTH_PASS;
}

// ── per-host catalog scope ──────────────────────────────────────────────────────
// china.designerwallcoverings.com → Greenland-vendor catalog ONLY (source:'greenland').
// japan. (or anything else / localhost) → the Japanese catalog, with any greenland row
// EXCLUDED. The scope pre-filters ROWS BEFORE any user filter and overrides f.source so
// the two hosts can never leak into each other.
function hostScope(req) {
  const host = String(req.headers.host || '').toLowerCase().split(':')[0];
  if (host.startsWith('china.')) {
    return { id: 'china', label: 'China — Greenland', sub: 'Greenland vendor · staged offline · read-only preview',
      sources: ['greenland'], tabs: [{ src: 'greenland', label: 'Greenland' }] };
  }
  // per-distributor microsite: <slug>.designerwallcoverings.com → only that distributor's rows.
  // Colliding brands (koroseal/harlequin/cole-son already own a subdomain) use <slug>-sg;
  // strip the -sg so both koroseal. and koroseal-sg. resolve to the same distributor scope.
  const sub = host.split('.')[0].replace(/-sg$/, '');
  if (DIST_SLUGS[sub] && !['japan', 'china', 'www', 'localhost'].includes(sub)) {
    const label = DIST_SLUGS[sub];
    return { id: 'dist-' + sub, label, sub: `${label} · Sangetsu distribution · internal · read-only`,
      sources: ['lilycolor', 'sangetsu'], distributor: label,
      tabs: [{ src: 'sangetsu', label: 'Sangetsu' }] };
  }
  // japan + local dev + anything else: the existing Japanese catalog, greenland excluded
  return { id: 'japan', label: 'Sangetsu + Lilycolor', sub: 'staged offline · read-only preview',
    sources: ['lilycolor', 'sangetsu'], tabs: [{ src: 'lilycolor', label: 'Lilycolor' }, { src: 'sangetsu', label: 'Sangetsu' }] };
}
// rows visible for this host (the pool every count + filter runs against);
// a distributor microsite pre-filters to that distributor's rows.
const scopedRows = (scope) => (scope.distributor
  ? ROWS.filter((r) => scope.sources.includes(r.source) && r.distributor === scope.distributor)
  : ROWS.filter((r) => scope.sources.includes(r.source)));

// gzip JSON responses when the client accepts it (the light /api/products list is
// ~15.9k rows — gzip takes it from ~7MB to ~1MB; internal-line-viewer spec).
function sendJson(req, res, obj) {
  const body = JSON.stringify(obj);
  if (/\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))) {
    zlib.gzip(body, (e, buf) => {
      if (e) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(body); }
      res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' });
      res.end(buf);
    });
    return;
  }
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(body);
}

const server = http.createServer(async (req, res) => {
  if (REQUIRE_AUTH && !authed(req)) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Japan/China — admin preview"' });
    return res.end('Authentication required');
  }
  const u = new URL(req.url, `http://localhost:${PORT}`);
  const scope = hostScope(req);
  const POOL = scopedRows(scope);
  // LIGHT full list for the client-side internal-line-viewer front end: every scoped row,
  // siblings capped (big Sangetsu patterns explode quadratically otherwise), no heavy fields.
  if (u.pathname === '/api/products') {
    const SIB_MAX = 12;
    const products = POOL.map((r) => ({
      source: r.source, sku: r.sku, prefix: r.prefix, title: r.title, collection: r.collection,
      width: r.width, repeat: r.repeat || null, composition: r.composition, functions: r.functions || [],
      fire: r.fire, fire_class: r.fire_class, price_yen: r.price_yen, price_band: r.price_band,
      image: r.image, image_state: r.image_state, image_count: r.image_count || 0,
      url: r.url, colorway_count: r.colorway_count, siblings: (r.siblings || []).slice(0, SIB_MAX),
      distributor: r.distributor, dist_direct: !!r.dist_direct,
      enriched: !!r.enriched, color_family: r.color_family, style: r.style, material: r.material,
      hex: r.hex, palette: r.palette || [], color_name: r.color_name || null,
    }));
    return sendJson(req, res, {
      total: products.length, scope: scope.id,
      family_order: FAMILY_ORDER, price_order: PRICE_ORDER, products,
    });
  }
  // tell the client which host scope it's in so it can set the H1/subtitle + hide the
  // irrelevant source tabs (china shows only Greenland; japan shows Lilycolor+Sangetsu).
  if (u.pathname === '/api/scope') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ id: scope.id, label: scope.label, sub: scope.sub, tabs: scope.tabs }));
    return;
  }
  // ── import queue: enqueue INTENT for onboarding (reversible; no prod write) ──
  if (u.pathname === '/api/import-queue') {
    const q = readQueue();
    return sendJson(req, res, { count: q.length, skus: q.map((r) => r.sku), items: q });
  }
  if (u.pathname === '/api/import/clear' && req.method === 'POST') {
    writeQueue([]);
    return sendJson(req, res, { count: 0, cleared: true });
  }
  if (u.pathname === '/api/import' && req.method === 'POST') {
    const body = await readBody(req);
    const want = Array.isArray(body.skus) ? body.skus : (body.sku ? [body.sku] : []);
    const bySku = new Map(POOL.map((r) => [String(r.sku), r]));   // scope pool: china can't queue a japan sku
    const q = readQueue();
    const have = new Set(q.map((r) => String(r.sku)));
    let queued = 0, already = 0, missing = 0;
    for (const raw of want) {
      const sku = String(raw);
      if (have.has(sku)) { already++; continue; }
      const r = bySku.get(sku);
      if (!r) { missing++; continue; }
      q.push({ queued_at: new Date().toISOString(), scope: scope.id, source: r.source, sku: r.sku,
        distributor: r.distributor || null, dist_direct: !!r.dist_direct, title: r.title || null,
        collection: r.collection || null, url: r.url || null, image: r.image || null,
        price_yen: r.price_yen || null, status: 'queued' });
      have.add(sku); queued++;
    }
    writeQueue(q);
    return sendJson(req, res, { queued, already, missing, count: q.length });
  }
  if (u.pathname === '/api/import/remove' && req.method === 'POST') {
    const body = await readBody(req);
    const rm = new Set((Array.isArray(body.skus) ? body.skus : (body.sku ? [body.sku] : [])).map(String));
    const q = readQueue().filter((r) => !rm.has(String(r.sku)));
    writeQueue(q);
    return sendJson(req, res, { count: q.length, removed: [...rm] });
  }
  // ── visual match: click a Sangetsu item → color-ranked candidates from its distributor's DW catalog ──
  if (u.pathname === '/api/match') {
    const sku = u.searchParams.get('sku') || '';
    const dist = u.searchParams.get('dist') || '';
    // disambiguate by distributor first (SKUs repeat across patterns → wrong-vendor bug); fall back to sku-only
    const item = POOL.find((r) => String(r.sku) === sku && (!dist || r.distributor === dist)) || POOL.find((r) => String(r.sku) === sku);
    if (!item) return sendJson(req, res, { error: 'not found' });
    const vendor = DIST_VENDOR[item.distributor] || null;
    const cands = vendor ? (DIST_COLORS[vendor] || []) : [];
    const iw = wordset(item.title);
    const ip = (item.palette && item.palette.length) ? item.palette : (item.hex ? [item.hex] : []);
    let ranked = [];
    if (ip.length && cands.length) {
      ranked = cands.map((c) => {
        const overlap = Math.min([...wordset(c.name)].filter((w) => iw.has(w)).length, 2); // capped
        return { c, d: paletteDist(ip, (c.palette && c.palette.length) ? c.palette : [c.hex]) - overlap * 30 };
      }).sort((a, b) => a.d - b.d).slice(0, 12);
    } else if (cands.length) {
      ranked = cands.map((c) => ({ c, d: [...wordset(c.name)].filter((w) => iw.has(w)).length ? 0 : 1 }))
        .sort((a, b) => a.d - b.d).slice(0, 12);
    }
    const pubimg = (item.image && /^https?:/.test(item.image)) ? item.image : null;
    return sendJson(req, res, {
      item: { sku: item.sku, title: item.title, image: item.image, hex: item.hex || null, palette: ip,
              color_family: item.color_family || null, distributor: item.distributor || null, url: item.url || null },
      vendor, catalog_size: cands.length,
      matches: ranked.map(({ c, d }) => ({ sku: c.sku, name: c.name, color_name: c.color_name, image: c.image_url,
              product_url: c.product_url, hex: c.hex, palette: c.palette, family: c.family, score: Math.round(d) })),
      methods: {
        dw_unified: !!vendor,
        reverse_image: pubimg ? `https://lens.google.com/uploadbyurl?url=${encodeURIComponent(pubimg)}`
                              : `https://www.google.com/search?tbm=isch&q=${encodeURIComponent((item.distributor || '') + ' ' + item.title)}`,
        source: item.url || null,
      },
    });
  }
  if (u.pathname === '/api/skus') {
    const f = parseFilters(u);
    // host scope overrides the user-supplied source so a stale localStorage tab can't
    // request lilycolor on china (or greenland on japan); clamp to in-scope sources only.
    if (!scope.sources.includes(f.source)) f.source = 'all';
    const sort = u.searchParams.get('sort') || 'newest';
    const offset = parseInt(u.searchParams.get('offset') || '0', 10);
    const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10), 500);
    let rows = applyFilters(POOL, f);
    const counts = { all: POOL.length };
    for (const s of scope.sources) counts[s] = POOL.filter((r) => r.source === s).length;
    if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
    const page = rows.slice(offset, offset + limit);
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ total: rows.length, counts, offset, limit, rows: page }));
    return;
  }
  // facet counts — each dimension counted against the OTHER active filters (drill-down)
  if (u.pathname === '/api/facets') {
    const f = parseFilters(u);
    if (!scope.sources.includes(f.source)) f.source = 'all';
    const tally = (rows, key) => { const m = {}; for (const r of rows) { const v = r[key]; if (v) m[v] = (m[v] || 0) + 1; } return m; };
    const tallyArr = (rows, key) => { const m = {}; for (const r of rows) { for (const v of (r[key] || [])) m[v] = (m[v] || 0) + 1; } return m; };
    const enrRows = applyFilters(POOL, f, { skip: 'enriched' });
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      color_family: tally(applyFilters(POOL, f, { skip: 'color' }), 'color_family'),
      style: tally(applyFilters(POOL, f, { skip: 'style' }), 'style'),
      material: tally(applyFilters(POOL, f, { skip: 'material' }), 'material'),
      collection: tally(applyFilters(POOL, f, { skip: 'collection' }), 'collection'),
      width: tally(applyFilters(POOL, f, { skip: 'width' }), 'width'),
      fire_class: tally(applyFilters(POOL, f, { skip: 'fire' }), 'fire_class'),
      functions: tallyArr(applyFilters(POOL, f, { skip: 'funcs' }), 'functions'),
      price_band: tally(applyFilters(POOL, f, { skip: 'price' }), 'price_band'),
      image_state: tally(applyFilters(POOL, f, { skip: 'image' }), 'image_state'),
      distributor: tally(applyFilters(POOL, f, { skip: 'distributor' }), 'distributor'),
      price_order: PRICE_ORDER,
      enriched: enrRows.filter((r) => r.enriched).length,
      unenriched: enrRows.filter((r) => !r.enriched).length,
      total: applyFilters(POOL, f).length,
      family_order: FAMILY_ORDER,
    }));
    return;
  }
  // locally-fetched Lily pattern swatches from Henry
  if (u.pathname.startsWith('/img/lily/')) {
    const sku = decodeURIComponent(u.pathname.slice('/img/lily/'.length)).replace(/[^A-Za-z0-9-]/g, '');
    const f = path.join(HENRY_IMG, `${sku}.jpg`);
    if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
    const f2 = allcIndex[normSku(sku)];
    if (f2 && fs.existsSync(f2)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f2)); }
    res.writeHead(404); return res.end('no image');
  }
  if (u.pathname.startsWith('/img/greenland/')) {
    const sku = decodeURIComponent(u.pathname.slice('/img/greenland/'.length));
    const f = path.join(GREEN_IMG, `${normSku(sku)}.jpg`);
    if (f.startsWith(GREEN_IMG) && fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
    res.writeHead(404); return res.end('no image');
  }
  if (u.pathname.startsWith('/img/sangetsu/')) {
    const sku = decodeURIComponent(u.pathname.slice('/img/sangetsu/'.length));
    const f = path.join(SANG_IMG, `${normSku(sku)}.jpg`);
    if (f.startsWith(SANG_IMG) && fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
    res.writeHead(404); return res.end('no image');
  }
  // static
  let p = u.pathname === '/' ? '/index.html' : u.pathname;
  const file = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
  if (!file.startsWith(PUBLIC) || !fs.existsSync(file)) { res.writeHead(404); res.end('not found'); return; }
  const ext = path.extname(file);
  const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }[ext] || 'text/plain';
  res.writeHead(200, { 'Content-Type': mime });
  res.end(fs.readFileSync(file));
});

server.listen(PORT, () => {
  console.log(`Staging viewer → http://localhost:${PORT}  (lilycolor ${ROWS.filter((r) => r.source === 'lilycolor').length} / sangetsu ${ROWS.filter((r) => r.source === 'sangetsu').length} / greenland ${ROWS.filter((r) => r.source === 'greenland').length} SKUs)`);
});