← back to Dw Pairs Well

server.js

1329 lines

require('dotenv').config();
const express = require('express');
const { Pool } = require('pg');
const path = require('path');
const { parseTags, classify, score, scoreSimilar } = require('./lib/classify');
const { hexToLab, deltaE2000, safeParseHexArray } = require('./lib/color');
const { nearestPaintsBatch } = require('./lib/paint');
const { generateCoordinates, renderCustom } = require('./lib/ai-stripes');

const { COLORS } = require('./lib/classify');

const PORT = Number(process.env.PORT || 9799);
const DATABASE_URL = process.env.DATABASE_URL;
const CORS_ORIGINS = (process.env.CORS_ORIGIN || '*')
  .split(',').map(s => s.trim()).filter(Boolean);

// CLIP visual-search service (dw-photo-capture/visual-search/search_service.py).
// Used by /api/similar?engine=clip for image-embedding nearest-neighbour. Optional:
// if unreachable OR the source SKU has no stored vector, /api/similar transparently
// falls back to the tag engine, so this is a pure enhancement.
const VISUAL_SEARCH_URL = process.env.VISUAL_SEARCH_URL || 'http://127.0.0.1:9914';

// CLIP blend can be turned ON for ALL /api/pairs callers (not just ?engine=clip) by
// setting PAIRS_CLIP_DEFAULT=1. Defaults OFF so shipping this is a no-op in prod until
// the visual-search service is confirmed reachable from the serving host. ?engine=tag
// is an explicit per-request override that force-disables the blend even when default-on.
const PAIRS_CLIP_DEFAULT = /^(1|true|on)$/i.test(process.env.PAIRS_CLIP_DEFAULT || '');
// Same one-switch for /api/similar: SIMILAR_CLIP_DEFAULT=1 makes the CLIP color+texture
// engine the default for all callers (per-request ?engine=tag still forces tag).
const SIMILAR_CLIP_DEFAULT = /^(1|true|on)$/i.test(process.env.SIMILAR_CLIP_DEFAULT || '');
// Latency guardrail: a short per-call timeout + a circuit-breaker so an unreachable/slow
// visual-search service never adds more than one slow call per cooldown window to customer
// traffic. On any failure we "trip" and skip the service for CLIP_COOLDOWN_MS.
const CLIP_TIMEOUT_MS  = Number(process.env.CLIP_TIMEOUT_MS  || 1500);
const CLIP_COOLDOWN_MS = Number(process.env.CLIP_COOLDOWN_MS || 30000);
let _clipDownUntil = 0;
function clipCircuitOpen() { return Date.now() < _clipDownUntil; }
function clipTrip()  { _clipDownUntil = Date.now() + CLIP_COOLDOWN_MS; }
function clipReset() { _clipDownUntil = 0; }

if (!DATABASE_URL) {
  console.error('FATAL: DATABASE_URL not set — copy .env.example to .env');
  process.exit(1);
}

const pool = new Pool({ connectionString: DATABASE_URL });

// "Link works on the live store" predicate. Probed at boot: if this DB's
// shopify_products lacks online_store_published (schema drift between the Mac2
// mirror and Kamatera), degrade to status-only filtering and say so loudly —
// a weaker guarantee beats 500ing every suggestion endpoint.
let PUB_SQL = 'AND sp.online_store_published IS TRUE';
pool.query('SELECT online_store_published FROM shopify_products LIMIT 0')
  .then(() => console.log('[link-guarantee] online_store_published present — full ACTIVE+published gate on'))
  .catch(() => {
    PUB_SQL = '';
    console.error('[link-guarantee] WARNING: shopify_products.online_store_published MISSING in this DB — degrading to status-only filtering');
  });

const app = express();
app.disable('x-powered-by');

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (CORS_ORIGINS.includes('*') || (origin && CORS_ORIGINS.includes(origin))) {
    res.setHeader('Access-Control-Allow-Origin', origin || '*');
    res.setHeader('Vary', 'Origin');
  }
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});

app.use(express.static(path.join(__dirname, 'public')));

// Root → the Design Coordinate preview
app.get('/', (_req, res) => res.redirect(302, '/preview.html'));

app.get('/healthz', async (_req, res) => {
  try {
    const r = await pool.query('SELECT 1 AS ok');
    res.json({ ok: true, db: r.rows[0].ok === 1, ts: new Date().toISOString() });
  } catch (e) {
    res.status(500).json({ ok: false, error: e.message });
  }
});

const SAFE_KEY = /^[A-Za-z0-9._\-]{1,128}$/;
// pattern_id grouping keys embed "::" segments (vendorSlug::source::root), so they
// need a wider charset than SAFE_KEY (which gates handles/SKUs).
const SAFE_PATTERN_ID = /^[A-Za-z0-9._:∅\- ]{1,200}$/;
const HEX_RE   = /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/;

// Parse a `palette=` query — comma-separated hex codes. Used by external sources
// (e.g. wallco.ai design pages) that aren't in shopify_products but still want
// to get coordinating stripes/plaids matched to a color story.
function parsePaletteParam(raw) {
  if (!raw) return [];
  return String(raw).split(',')
    .map(s => s.trim())
    .filter(s => HEX_RE.test(s))
    .map(s => s.startsWith('#') ? s : ('#' + s))
    .slice(0, 8);
}

const SOURCE_COLS = `
  sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url, sp.tags, sp.pattern_name, sp.status,
  sce.dominant_hex, sce.background_hex, sce.hex_codes, sce.color_family
`;

async function loadOne(col, val) {
  const q = `SELECT ${SOURCE_COLS}
       FROM shopify_products sp
       LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
       WHERE sp.${col} = $1
       LIMIT 1`;
  const r = await pool.query(q, [val]);
  return r.rows[0] || null;
}

// Resolve the source product. 2026-06-27 (vp-dw-commerce): the DW theme passes
// data-product-sku = custom.manufacturer_sku as ?dw_sku=, which never matches
// shopify_products.dw_sku (a DWxx key) — so a wrong dw_sku used to short-circuit
// (else-if) the handle path and return "source not found", breaking the whole
// Design-Coordinate panel. Fix: TRY dw_sku, and if it doesn't resolve, FALL BACK
// to handle. A bad sku can no longer block a good handle.
async function loadSource({ dw_sku, handle }) {
  let src = null;
  if (dw_sku) src = await loadOne('dw_sku', dw_sku);
  if (!src && handle) src = await loadOne('handle', handle);
  return src;
}

// "Coordinate" patterns in the interior-design sense — what a designer uses to
// COORDINATE a hero pattern. Stripes/plaids/checks/tartans/gingham/ticking only.
// Florals coordinate against florals visually compete; stripes never do.
const COORDINATE_MOTIFS = ['stripe', 'striped', 'plaid', 'check', 'checked', 'checkered', 'tartan', 'gingham', 'ticking'];

async function loadCandidates(source, limit = 600) {
  // Hard motif filter: candidate MUST have a stripe/plaid/check tag.
  // We don't filter by color here — color overlap is the scoring signal, but
  // we want a wide candidate pool so the algorithm can pick the best palette match.
  const motifClauses = COORDINATE_MOTIFS.map((_, i) => `lower(sp.tags) LIKE $${i + 3}`).join(' OR ');
  const motifPatterns = COORDINATE_MOTIFS.map(m => `%${m}%`);

  const params = [source.dw_sku || '', limit, ...motifPatterns];

  const q = `
    SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url, sp.tags, sp.pattern_name,
           sce.dominant_hex, sce.background_hex, sce.hex_codes, sce.color_family
    FROM shopify_products AS sp
    LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
    WHERE sp.status = 'ACTIVE'
      ${PUB_SQL}
      AND sp.image_url IS NOT NULL
      AND sp.handle IS NOT NULL
      AND sp.dw_sku IS NOT NULL
      AND sp.dw_sku <> $1
      AND (${motifClauses})
    ORDER BY (sce.dominant_hex IS NOT NULL) DESC,
             (sce.hex_codes IS NOT NULL)    DESC,
             sp.synced_at DESC NULLS LAST
    LIMIT $2
  `;
  const r = await pool.query(q, params);
  return r.rows;
}

function detectCoordinateMotif(tags) {
  if (!tags) return null;
  const lower = String(tags).toLowerCase();
  for (const m of ['plaid','tartan','gingham','check','ticking','stripe','striped','checked','checkered']) {
    if (lower.indexOf(m) !== -1) return m === 'striped' ? 'stripe' : (m === 'checked' || m === 'checkered' ? 'check' : m);
  }
  return null;
}

const COLOR_NAME = [
  // hue-name lookup keyed by approximate Lab — used only for the "Picks up:" explanation
  { name: 'black',    L: 10  },
  { name: 'white',    L: 95  },
  { name: 'cream',    L: 92, a: 3,  b: 14 },
  { name: 'beige',    L: 80, a: 6,  b: 18 },
  { name: 'taupe',    L: 60, a: 4,  b: 8  },
  { name: 'brown',    L: 38, a: 14, b: 22 },
  { name: 'ochre',    L: 65, a: 12, b: 50 },
  { name: 'gold',     L: 70, a: 8,  b: 60 },
  { name: 'mustard',  L: 70, a: 10, b: 70 },
  { name: 'olive',    L: 50, a: -8, b: 35 },
  { name: 'sage',     L: 65, a: -15, b: 12 },
  { name: 'green',    L: 50, a: -40, b: 25 },
  { name: 'teal',     L: 50, a: -25, b: -10 },
  { name: 'blue',     L: 45, a: 0,  b: -45 },
  { name: 'navy',     L: 20, a: 0,  b: -35 },
  { name: 'lavender', L: 70, a: 12, b: -18 },
  { name: 'purple',   L: 35, a: 30, b: -35 },
  { name: 'pink',     L: 75, a: 25, b: 8  },
  { name: 'rose',     L: 60, a: 35, b: 12 },
  { name: 'red',      L: 45, a: 60, b: 35 },
  { name: 'rust',     L: 45, a: 35, b: 40 },
  { name: 'orange',   L: 65, a: 35, b: 60 },
  { name: 'gray',     L: 60, a: 0,  b: 0  }
];

function nameHex(hex) {
  const lab = hexToLab(hex);
  if (!lab) return null;
  // grayscale shortcut
  const sat = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
  if (sat < 6) {
    if (lab.L > 90) return 'white';
    if (lab.L > 70) return 'light gray';
    if (lab.L > 35) return 'gray';
    if (lab.L > 15) return 'charcoal';
    return 'black';
  }
  let best = null, bestD = Infinity;
  for (const c of COLOR_NAME) {
    const dL = lab.L - c.L;
    const da = (c.a == null ? 0 : lab.a - c.a);
    const db = (c.b == null ? 0 : lab.b - c.b);
    const d = dL*dL + da*da + db*db;
    if (d < bestD) { bestD = d; best = c.name; }
  }
  return best;
}

function paletteOf(row) {
  // Build a clean hex list: dominant first, then bg, then the rest of hex_codes,
  // de-duped (case-insensitive), capped at 5.
  var out = [];
  var seen = new Set();
  function push(h) {
    if (!h) return;
    var key = String(h).toLowerCase();
    if (seen.has(key)) return;
    seen.add(key); out.push(h);
  }
  push(row.dominant_hex);
  push(row.background_hex);
  safeParseHexArray(row.hex_codes).forEach(push);
  return out.slice(0, 5);
}

// Palette-vs-palette color overlap score.
// For each source color, find the BEST ΔE2000 match in the candidate palette.
// Award a bonus per color and report which source colors the candidate "picks up".
function paletteOverlap(sourcePalette, candPalette) {
  if (!sourcePalette || !candPalette) return { score: 0, pickedUp: [], avgDE: null };
  const sourceLabs = sourcePalette.map(h => ({ hex: h, lab: hexToLab(h), name: nameHex(h) }));
  const candLabs   = candPalette.map(h => ({ hex: h, lab: hexToLab(h) }));
  if (!sourceLabs.length || !candLabs.length) return { score: 0, pickedUp: [], avgDE: null };

  let total = 0;
  const pickedUp = [];
  const allDE = [];
  for (const s of sourceLabs) {
    if (!s.lab) continue;
    let bestDE = Infinity;
    for (const c of candLabs) {
      if (!c.lab) continue;
      const dE = deltaE2000(s.lab, c.lab);
      if (dE < bestDE) bestDE = dE;
    }
    if (!Number.isFinite(bestDE)) continue;
    allDE.push(bestDE);
    // perceptual bands → bonus
    let bonus;
    if      (bestDE <= 10) bonus = 4;
    else if (bestDE <= 20) bonus = 2;
    else if (bestDE <= 35) bonus = 1;
    else                   bonus = 0;
    total += bonus;
    if (bonus > 0 && s.name && !pickedUp.includes(s.name)) pickedUp.push(s.name);
  }
  const avgDE = allDE.length ? (allDE.reduce((a, b) => a + b, 0) / allDE.length) : null;
  return { score: total, pickedUp, avgDE };
}

// Batch-load enrichment rows for a set of dw_skus (for CLIP-result UI parity:
// title, palette, vendor, handle). First row per dw_sku wins.
async function loadRowsByDwSkus(skus) {
  if (!skus || !skus.length) return [];
  // ACTIVE + Online-Store-published only: a CLIP result without a row here is NOT
  // sellable/linkable on the live store and gets dropped by clipSimilarTop.
  const q = `SELECT ${SOURCE_COLS}
       FROM shopify_products sp
       LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
       WHERE sp.dw_sku = ANY($1)
         AND sp.status = 'ACTIVE'
         ${PUB_SQL}
         AND sp.handle IS NOT NULL`;
  const r = await pool.query(q, [skus]);
  const seen = new Set(), out = [];
  for (const row of r.rows) {
    if (!row.dw_sku || seen.has(row.dw_sku)) continue;
    seen.add(row.dw_sku); out.push(row);
  }
  return out;
}

// CLIP nearest-neighbour "similar" via the visual-search service. Returns an array
// in the SAME shape pickSimilar() produces (_score/_palette/_dE/_why) so the shared
// palette+paint response tail is reused verbatim. Returns null on any miss (service
// down, source SKU never embedded, empty result) → caller falls back to the tag engine.
async function clipSimilarTop(source, k) {
  if (!source.dw_sku) return null;
  if (clipCircuitOpen()) return null;                   // service recently failed → skip, no latency hit
  try {
    // Over-fetch 4x: results get dropped by BOTH the ACTIVE+published gate and the
    // color lock below, and the strip should still fill to k.
    const body = { dw_sku: source.dw_sku, hex: source.dominant_hex || '', k: Math.min(k * 4, 50) };
    const r = await fetch(`${VISUAL_SEARCH_URL}/similar`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(CLIP_TIMEOUT_MS)
    });
    if (!r.ok) {                                        // 404 = no stored vector for this sku (per-sku miss, NOT an outage)
      if (r.status >= 500) clipTrip();                  // 5xx = service alive but erroring → trip the breaker
      return null;
    }
    const d = await r.json();
    clipReset();                                        // reachable → close the breaker
    if (!d || !d.ok || !Array.isArray(d.results) || d.results.length === 0) return null;

    const skus = d.results.map(x => x.dw_sku).filter(Boolean);
    const rowByS = new Map((await loadRowsByDwSkus(skus)).map(row => [row.dw_sku, row]));
    const sourcePalette = paletteOf(source);

    // HARD LINK GUARANTEE: only products verified ACTIVE + Online-Store-published
    // (loadRowsByDwSkus filters to exactly those) may be suggested — every /products/
    // link the UI renders must resolve on the live store.
    //
    // COLOR + TEXTURE LOCK (Steve, 2026-07-13): CLIP cosine locks the texture/style;
    // the hex palette locks the color. When the source has hex enrichment, a candidate
    // must pick up ≥1 source color (ΔE ≤ 35, paletteOverlap ≥ 1) to qualify; survivors
    // re-rank by cosine + a small color term so tighter palette matches rise. If the
    // lock would empty the strip (rare), pure-cosine order backfills — never blank.
    const scored = [];
    for (const x of d.results) {
      const row = rowByS.get(x.dw_sku);
      if (!row || !row.handle) continue;
      const candPalette = paletteOf(row);
      const overlap = paletteOverlap(sourcePalette, candPalette);
      const why = [`Visual match (CLIP · cosine ${Number(x.score).toFixed(3)})`];
      if (overlap.pickedUp.length) why.push(`Picks up: ${overlap.pickedUp.slice(0, 3).join(', ')}`);
      scored.push({
        dw_sku: x.dw_sku,
        handle: row.handle,
        title: row.title || x.pattern || x.dw_sku,
        vendor: row.vendor || x.vendor || null,
        image_url: row.image_url || x.image || null,
        pattern_name: row.pattern_name || x.pattern || null,
        _score: x.score, _palette: candPalette, _dE: overlap.avgDE, _why: why,
        _colorLocked: overlap.score >= 1,
        _combined: Number(x.score) + 0.02 * Math.min(overlap.score, 8)
      });
    }
    const hasSourcePalette = sourcePalette.length > 0;
    const locked = hasSourcePalette ? scored.filter(s => s._colorLocked) : scored;
    locked.sort((a, b) => b._combined - a._combined);
    const out = locked.slice(0, k);
    if (out.length < k) {           // backfill from unlocked, best cosine first
      const rest = scored.filter(s => !out.includes(s)).sort((a, b) => b._score - a._score);
      out.push(...rest.slice(0, k - out.length));
    }
    return out.length ? out : null;
  } catch (e) {
    clipTrip();                                         // unreachable/slow → open the breaker
    console.error('clipSimilarTop fallback →', e.message);
    return null;
  }
}

// CLIP style-world sub-score for PAIRS. A coordinate should share the hero's style
// world WITHOUT visually competing — so mid-similarity is rewarded and near-duplicates
// (same pattern re-colored) are penalized. Neutral (0) when the candidate has no
// vector, so the un-embedded tail is never punished.
const CLIP_NEAR_DUP  = 0.88;  // ≥ this = visually the same design → penalize
const CLIP_STYLE_HI  = 0.55;  // sweet spot lower bound → +4
const CLIP_STYLE_LO  = 0.45;  // weak style kinship → +2
function clipPairBonus(cos) {
  if (cos == null || !Number.isFinite(cos)) return 0;
  if (cos >= CLIP_NEAR_DUP) return -4;
  if (cos >= CLIP_STYLE_HI) return 4;
  if (cos >= CLIP_STYLE_LO) return 2;
  return 0;
}

// Bulk source-vs-candidates cosine map from the visual-search service.
// Returns {} on any miss (service down / source not embedded) — blend degrades to
// the pure tag+palette scorer, never blocks the response.
async function clipPairwiseMap(source, candidates) {
  if (!source.dw_sku) return {};
  if (clipCircuitOpen()) return {};                     // service recently failed → skip, no latency hit
  try {
    const skus = candidates.map(c => c.dw_sku).filter(Boolean);
    if (!skus.length) return {};
    const r = await fetch(`${VISUAL_SEARCH_URL}/pairwise`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ dw_sku: source.dw_sku, candidates: skus }),
      signal: AbortSignal.timeout(CLIP_TIMEOUT_MS)
    });
    if (!r.ok) {
      if (r.status >= 500) clipTrip();                  // 5xx = service alive but erroring → trip the breaker
      return {};
    }
    const d = await r.json();
    clipReset();                                        // reachable → close the breaker
    return (d && d.ok && d.cosines) ? d.cosines : {};
  } catch (e) {
    clipTrip();                                         // unreachable/slow → open the breaker
    console.error('clipPairwiseMap fallback →', e.message);
    return {};
  }
}

function pickPairs(source, candidates, k = 24, clipMap = null) {
  const sCls = classify(parseTags(source.tags));
  const sourcePalette = paletteOf(source);

  const scored = candidates.map(c => {
    const cCls = classify(parseTags(c.tags));
    const tagScore = score(sCls, cCls, c.vendor, source.vendor, c.pattern_name, source.pattern_name);
    const candPalette = paletteOf(c);
    const motif = detectCoordinateMotif(c.tags);
    const overlap = paletteOverlap(sourcePalette, candPalette);

    // Strong bias: motif is the whole point of this feature.
    const motifBonus = motif ? 6 : 0;
    const clipCos = clipMap ? clipMap[c.dw_sku] : null;
    const clipBonus = clipPairBonus(clipCos);
    const totalScore = tagScore.score + overlap.score + motifBonus + clipBonus;

    const why = [];
    if (motif)            why.push(`Coordinating ${motif}`);
    if (overlap.pickedUp.length) why.push(`Picks up: ${overlap.pickedUp.slice(0, 4).join(', ')}`);
    if (clipBonus > 0)    why.push(`Same style world (CLIP ${Number(clipCos).toFixed(2)})`);
    if (overlap.avgDE != null)   why.push(`palette ΔE avg ${overlap.avgDE.toFixed(1)}`);
    if (tagScore.why.length) why.push(tagScore.why[0]);

    return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: overlap.avgDE, _motif: motif, _pickedUp: overlap.pickedUp, _overlapScore: overlap.score, _clipCos: clipCos };
  });

  // HARD FILTER per Steve's rule: every coordinate MUST use colors from the source palette.
  // If the source has hex enrichment, require palette overlap ≥ 1 (ΔE ≤ 35 to any source color).
  // If the source has NO hex data (~9% of catalog — Phillipe Romano / Brunschwig / Thibaut etc.
  // not yet Gemini-enriched), fall back to tag-based color matching so the feature still works.
  const hasSourcePalette = sourcePalette.length > 0;
  const eligible = hasSourcePalette
    ? scored.filter(x => x._overlapScore >= 1 && x._pickedUp.length >= 1)
    : scored.filter(x => x._score >= 5);  // motif (+6) is already in; require some tag overlap too

  eligible.sort((a, b) => b._score - a._score);

  // Diversity: variety of motifs + vendors + colorways. Tuned for 24 cards.
  const out = [];
  const seenPatterns = new Set();
  const motifCount = new Map();
  const vendorCount = new Map();
  for (const x of eligible) {
    if (out.length >= k) break;
    const pat = (x.pattern_name || x.handle || '').toLowerCase();
    if (pat && seenPatterns.has(pat)) continue;
    const vc = vendorCount.get(x.vendor) || 0;
    if (vc >= 6) continue;                       // cap any single vendor at 6/24
    const mc = motifCount.get(x._motif) || 0;
    if (mc >= 16) continue;                       // cap any single motif at 16/24
    out.push(x);
    if (pat) seenPatterns.add(pat);
    vendorCount.set(x.vendor, vc + 1);
    motifCount.set(x._motif, mc + 1);
  }
  return out;
}

// ============================================================
//  "Similar Designs" — visual ALTERNATIVES to the source (NOT coordinates).
//  Candidate pool is anchored on the SOURCE's own motif/material tags so a
//  grasscloth surfaces other grasscloths, a damask other damasks. Scoring
//  (scoreSimilar) rewards sameness; color overlap is a soft bonus, not a gate.
// ============================================================
async function loadSimilarCandidates(source, limit = 1200) {
  const sCls = classify(parseTags(source.tags));
  // Anchor on the source's recognized motif + material tags.
  const anchors = [...sCls.motifs, ...sCls.materials];

  const params = [source.dw_sku || ''];
  let filterClause;
  if (anchors.length) {
    const likeClauses = anchors.map((_, i) => `lower(sp.tags) LIKE $${i + 2}`);
    filterClause = `(${likeClauses.join(' OR ')})`;
    anchors.forEach(a => params.push(`%${String(a).toLowerCase()}%`));
  } else if (source.color_family) {
    // No motif/material on source — fall back to same color family so the pool stays relevant.
    filterClause = `lower(sp.tags) LIKE $2`;
    params.push(`%${String(source.color_family).toLowerCase()}%`);
  } else {
    filterClause = 'TRUE';
  }
  params.push(limit);
  const limitIdx = params.length;

  const q = `
    SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url, sp.tags, sp.pattern_name,
           sce.dominant_hex, sce.background_hex, sce.hex_codes, sce.color_family
    FROM shopify_products AS sp
    LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
    WHERE sp.status = 'ACTIVE'
      ${PUB_SQL}
      AND sp.image_url IS NOT NULL
      AND sp.handle IS NOT NULL
      AND sp.dw_sku IS NOT NULL
      AND sp.dw_sku <> $1
      AND ${filterClause}
    ORDER BY (sce.dominant_hex IS NOT NULL) DESC,
             (sce.hex_codes IS NOT NULL)    DESC,
             sp.synced_at DESC NULLS LAST
    LIMIT $${limitIdx}
  `;
  const r = await pool.query(q, params);
  return r.rows;
}

function pickSimilar(source, candidates, k = 12) {
  const sCls = classify(parseTags(source.tags));
  const sourcePalette = paletteOf(source);
  const hasAnchors = (sCls.motifs.size + sCls.materials.size) > 0;

  const scored = candidates.map(c => {
    const cCls = classify(parseTags(c.tags));
    const sim = scoreSimilar(sCls, cCls, c.vendor, source.vendor, c.pattern_name, source.pattern_name);
    const candPalette = paletteOf(c);
    const overlap = paletteOverlap(sourcePalette, candPalette);
    // color overlap is a SOFT bonus for "similar" (capped), never a hard filter
    const totalScore = sim.score + Math.min(overlap.score, 6);

    const why = sim.why.slice(0, 2);
    if (overlap.pickedUp.length) why.push(`Picks up: ${overlap.pickedUp.slice(0, 3).join(', ')}`);

    return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: overlap.avgDE, _samePattern: sim.samePattern };
  });

  // Drop this design's own colorways; require at least one real shared signal.
  const eligible = scored.filter(x => !x._samePattern && x._score >= (hasAnchors ? 5 : 3));
  eligible.sort((a, b) => b._score - a._score);

  // Diversity: one card per SIMILAR pattern (no colorway floods), looser vendor cap than pairs.
  const out = [];
  const seenPatterns = new Set();
  const vendorCount = new Map();
  for (const x of eligible) {
    if (out.length >= k) break;
    const pat = (x.pattern_name || x.handle || '').toLowerCase();
    if (pat && seenPatterns.has(pat)) continue;
    const vc = vendorCount.get(x.vendor) || 0;
    if (vc >= 8) continue;
    out.push(x);
    if (pat) seenPatterns.add(pat);
    vendorCount.set(x.vendor, vc + 1);
  }
  return out;
}

// ============================================================
//  WS-3 — COLORWAYS BACKBONE  (collapse colorways → one tile per pattern)
//
//  In PRODUCTION the grouping key is the Shopify metafield `custom.pattern_id`
//  written by tools/pattern-id-backfill.js (GATED). That metafield is NOT in
//  the read-only mirror, so for LOCAL exercisability TODAY these endpoints
//  derive an INTERIM grouping key with the SAME T2-title-base + series-aware
//  mfr_root logic used in pattern-id-backfill.js. When a `pattern_id` column
//  later exists in the mirror (or metafields jsonb carries custom.pattern_id),
//  groupKeyForRow() reads it first and the interim derivation is the fallback.
//
//  Key precedence per row (mirrors pattern-id-backfill.js):
//    1. custom.pattern_id metafield (prod truth) — read from metafields jsonb
//    2. series-aware mfr_root over mirror.mfr_sku  → `${vendorSlug}::mfr::${root}`
//    3. T2 normalized title minus color words      → `${vendorSlug}::t2::${t2}`
//    4. pattern_name last resort                    → `${vendorSlug}::pn::${key}`
// ============================================================

const GROUP_NOISE = [
  'wallcovering','wallcoverings','wallpaper','wall covering','fabric','fabrics',
  'durable vinyl','vinyl','grasscloth','print','commercial','residential',
  'type ii','type iii','mural','wall mural','peel and stick','peel-and-stick',
  'self adhesive','self-adhesive','by phillipe romano','phillipe romano'
];
const GROUP_COLOR_WORDS = new Set([...COLORS,
  'light','dark','deep','pale','soft','warm','cool','muted','bright','antique',
  'metallic','natural','neutral','multi','multicolor','multicolour'
]);

function gStripVendorSuffix(title) {
  const i = (title || '').indexOf('|');
  return (i === -1 ? (title || '') : title.slice(0, i)).trim();
}
function gNormTitle(title) {
  let s = gStripVendorSuffix(title).toLowerCase();
  for (const n of GROUP_NOISE) s = s.split(n).join(' ');
  s = s.replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
  return s;
}
function gStripColors(normalized) {
  const kept = normalized.split(' ').filter(w => w && !GROUP_COLOR_WORDS.has(w));
  return kept.join(' ').trim() || normalized;
}
// series-aware mfr_sku ROOT — drop a TRAILING colorway suffix, keep series root.
//   TAK-AA04-01 -> TAK-AA04 · FRL5226/01 -> FRL5226 · W7008-01 -> W7008 · AT9604 -> AT9604
function gMfrRoot(raw) {
  if (!raw) return null;
  let s = String(raw).trim().toUpperCase();
  if (!s) return null;
  const tokens = s.split(/[-/_.\s]+/).filter(Boolean);
  if (tokens.length >= 2) {
    const last = tokens[tokens.length - 1];
    if (/^\d{1,3}[A-Z]?$/.test(last)) return tokens.slice(0, -1).join('-');
  }
  return tokens.join('-');
}
function gVendorSlug(v) {
  return (v || '∅').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'no-vendor';
}
// Read custom.pattern_id from the metafields jsonb if present (prod truth path).
function patternIdFromMetafields(metafields) {
  if (!metafields) return null;
  let mf = metafields;
  try { if (typeof mf === 'string') mf = JSON.parse(mf); } catch (_) { return null; }
  if (!mf || typeof mf !== 'object') return null;
  // accept {custom:{pattern_id:...}} or {"custom.pattern_id":...} or [{namespace,key,value}]
  if (mf.custom && mf.custom.pattern_id) return String(mf.custom.pattern_id);
  if (mf['custom.pattern_id']) return String(mf['custom.pattern_id']);
  if (Array.isArray(mf)) {
    const hit = mf.find(m => m && m.namespace === 'custom' && m.key === 'pattern_id' && m.value);
    if (hit) return String(hit.value);
  }
  return null;
}
// AUTHORITATIVE pattern_id map (dw_sku -> pid), generated by tools/pattern-id-backfill.js
// WITH the junk-root guard applied (demotes over-merges like ::mfr::CORK / ::mfr::YORK,
// where one pure-alpha mfr root spans 8+ distinct designs). This is the SAME key written
// to Shopify custom.pattern_id, so the live grid/pattern-page group EXACTLY as the
// backfilled metafield does — eliminating the backfill-vs-live divergence that surfaced
// /pages/pattern?pattern_id=…::mfr::CORK as 16 unrelated cork designs under one tile.
// Falls back to the interim derivation for any dw_sku not present in the map.
const AUTH_PID = (() => {
  // WS-1 (2026-06-26): prefer the NEW title-first + color-invariant map (TH=30) — the
  // same keys re-backfilled to Shopify custom.pattern_id. Falls back to the OLD mfr-first
  // map only if the new one is absent, so a deploy before the re-backfill never regresses.
  for (const path of ['./tools/pattern-id-titlefirst-by-dwsku.json', './tools/pattern-id-by-dwsku.json']) {
    try {
      const raw = require(path);
      const m = new Map();
      for (const k in raw) { const v = raw[k]; if (v && v.pid) m.set(k, v.pid); }
      console.log(`[pattern_id] loaded ${m.size} authoritative grouping keys from ${path}`);
      return m;
    } catch (e) { /* try next */ }
  }
  console.warn('[pattern_id] authoritative map unavailable, using interim derivation');
  return new Map();
})();

// THE canonical grouping key for a product row. Authoritative backfilled key first
// (junk-root-guarded), then prod metafield, else interim derivation.
function groupKeyForRow(row) {
  const auth = row.dw_sku && AUTH_PID.get(row.dw_sku);
  if (auth) return auth;
  const prod = patternIdFromMetafields(row.metafields);
  if (prod) return prod;
  const vslug = gVendorSlug(row.vendor);
  const root = gMfrRoot(row.mfr_sku);
  if (root) return `${vslug}::mfr::${root}`;
  const t2 = gStripColors(gNormTitle(row.title));
  if (t2) return `${vslug}::t2::${t2}`;
  const pn = (row.pattern_name || '').toLowerCase().trim();
  return `${vslug}::pn::${pn || 'ungrouped'}`;
}

// Extract the REAL (scraped) colorway NAME for a card — never an AI color, never "Unknown".
// DW title shape is `Pattern RealColor[ - AIColorDescriptor Type] | Brand`. The real color
// is the words AFTER the pattern base and BEFORE any " - " AI descriptor. We compute the
// pattern base from the mfr_root group members' shared title prefix is overkill here, so we
// approximate: take the part before " - " (drops the AI descriptor), strip pattern/noise/AI
// words, and what remains is the manufacturer colorway name.
// Sources, in order:
//   1. real colorway phrase recovered from the title's pre-dash segment
//   2. enrichment color_family (tag-grade fallback)
//   3. the bare title base (never "Unknown")
function colorNameForRow(row, patternBaseWords) {
  const base = gStripVendorSuffix(row.title);                 // "Zendo Toffee - Beige Fabric"
  const preDash = base.split(' - ')[0].trim();                // "Zendo Toffee"
  // normalize the pre-dash segment, then remove pattern words + noise + AI color words
  let norm = preDash.toLowerCase();
  for (const n of GROUP_NOISE) norm = norm.split(n).join(' ');
  norm = norm.replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
  const patSet = new Set(patternBaseWords || []);
  const colorWords = norm.split(' ').filter(w => w && !patSet.has(w) && !GROUP_COLOR_WORDS.has(w));
  if (colorWords.length) {
    return colorWords.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
  }
  // if removing pattern words left nothing real, try the words present before-dash minus pattern only
  const colorWords2 = norm.split(' ').filter(w => w && !patSet.has(w));
  if (colorWords2.length) {
    return colorWords2.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
  }
  if (row.color_family) {
    const cf = String(row.color_family).trim();
    if (cf) return cf.charAt(0).toUpperCase() + cf.slice(1);
  }
  return base || null;
}

// Guard against degenerate pattern LABELS (display only — never the group key).
// Some ACTIVE products carry malformed titles/pattern_names that are just a vendor
// suffix ("| Osborne & Little Europe") or empty — rendering those verbatim shows the
// VENDOR (or a bare pipe) where the pattern name belongs. A leading "|" is the tell:
// the pattern segment before the vendor suffix was empty. When the computed label is
// empty / pipe-led / vendor-only, fall back to mfr_root → humanized handle → dw_sku
// so a tile never displays "| Vendor". Root cause is upstream catalog data (junk
// title/mfr_sku); this only hardens the widget against it.
function bestPatternLabel(row, computed) {
  const titleCase = s => String(s).replace(/\b\w/g, c => c.toUpperCase());
  const raw = String(computed || '').trim();
  const vendor = String(row.vendor || '').trim().toLowerCase();
  const s = raw.replace(/^[\s|·—–-]+/, '').replace(/[\s|·—–-]+$/, '').trim();
  const degenerate = !s
    || raw.startsWith('|')                                   // empty pattern base before vendor suffix
    || /^[\s|·—–-]+$/.test(raw)
    || s.toLowerCase() === vendor
    || s.toLowerCase().replace(/\s+(europe|usa|na|studios?)$/, '') === vendor;
  if (!degenerate) return titleCase(s);
  // DW handles are descriptive ("agapantha-trellis-mural-black") — prefer a humanized
  // handle over an ALL-CAPS mfr code, but only when it carries real words (not a bare
  // SKU slug like "eur-71203"). Then mfr_root (non-junk), then dw_sku.
  const h = String(row.handle || '')
    .replace(/-designer-wallcoverings-los-angeles$/i, '')
    .replace(/[-_]+/g, ' ').trim();
  const hasWords = /[a-z]{3,}/i.test(h.replace(/\b[a-z]{1,2}\d/gi, '')) && h.split(' ').some(w => /^[a-z]{3,}$/i.test(w));
  if (h && hasWords) return titleCase(h);
  const root = gMfrRoot(row.mfr_sku);
  if (root && !/^\d{1,3}$/.test(root)) return titleCase(root.replace(/[-_]+/g, ' '));
  if (h) return titleCase(h);                                // bare SKU-ish handle beats dw_sku
  return row.dw_sku || 'Pattern';
}

// Sanitize a product TITLE for recommendation lists (/api/pairs, /api/similar),
// which pass shopify `title` through verbatim. A junk title whose pattern segment
// is empty ("| DW Bespoke Studios") otherwise renders as a bare-pipe tile. ONLY
// degenerate titles are rewritten (via the bestPatternLabel fallback → handle /
// mfr_root / dw_sku); a normal "Name | Vendor" title is returned unchanged.
function safeTitle(row) {
  const raw = String(row.title || '').trim();
  const degenerate = !raw || raw.startsWith('|') || /^[\s|·—–-]+$/.test(raw);
  return degenerate ? bestPatternLabel(row, '') : row.title;
}

// Paint-chip lookup helpers — shared by /api/pairs and /api/similar.
// paintMap is the per-request result of nearestPaintsBatch; passed explicitly so both
// routes can use a single definition without sharing mutable state.
function paintsFor(hex, paintMap) {
  const m = paintMap[String(hex || '').toLowerCase()] || null;
  if (!m) return null;
  // Return all 7 brands; UI picks which to render. Short keys for transport size.
  return {
    sw:   m['sherwin-williams'] || null,
    de:   m['dunn-edwards']     || null,
    bm:   m['benjamin-moore']   || null,
    behr: m['behr']             || null,
    ppg:  m['ppg']              || null,
    fb:   m['farrow-ball']      || null,
    ral:  m['ral']              || null
  };
}
function paletteWithPaints(hexList, paintMap) {
  return hexList.map(h => ({ hex: h, paints: paintsFor(h, paintMap) }));
}

// Resolve a single product to its group key (used when caller passes handle/dw_sku).
async function resolveGroupKey({ dw_sku, handle, pattern_id }) {
  if (pattern_id) return pattern_id;
  const sel = `dw_sku, handle, title, vendor, mfr_sku, pattern_name, metafields`;
  let q, params;
  if (dw_sku)      { q = `SELECT ${sel} FROM shopify_products WHERE dw_sku = $1 LIMIT 1`; params = [dw_sku]; }
  else if (handle) { q = `SELECT ${sel} FROM shopify_products WHERE handle = $1 LIMIT 1`; params = [handle]; }
  else return null;
  const r = await pool.query(q, params);
  if (!r.rows[0]) return null;
  return groupKeyForRow(r.rows[0]);
}

// GET /api/colorways?pattern_id=… | ?handle=… | ?dw_sku=…
//   → { pattern_id, pattern, vendor, count, colorways:[{dw_sku,handle,title,color_name,color_hex,image_url}] }
app.get('/api/colorways', async (req, res) => {
  try {
    const pattern_id = req.query.pattern_id && SAFE_PATTERN_ID.test(req.query.pattern_id) ? req.query.pattern_id : null;
    const dw_sku     = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
    const handle     = req.query.handle && SAFE_KEY.test(req.query.handle) ? req.query.handle : null;
    if (!pattern_id && !dw_sku && !handle) {
      return res.status(400).json({ ok: false, error: 'pass ?pattern_id=…, ?handle=…, or ?dw_sku=…' });
    }

    const key = await resolveGroupKey({ dw_sku, handle, pattern_id });
    if (!key) return res.status(404).json({ ok: false, error: 'group not found for that handle/sku' });

    // Pull all ACTIVE products in this vendor's space, group in JS by groupKeyForRow,
    // and return the members of the matched key. Vendor-scoped narrows the scan since
    // the key embeds the vendor slug as its first segment.
    const vendorSlug = key.split('::')[0];
    const r = await pool.query(`
      SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.mfr_sku, sp.pattern_name,
             sp.image_url, sp.metafields, sce.color_family, sce.dominant_hex, sce.background_hex
      FROM shopify_products sp
      LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
      WHERE sp.status = 'ACTIVE' ${PUB_SQL}
        AND sp.title IS NOT NULL AND sp.handle IS NOT NULL
        AND sp.dw_sku IS NOT NULL
        AND lower(regexp_replace(coalesce(sp.vendor,'∅'),'[^a-zA-Z0-9]+','-','g')) LIKE $1
    `, [vendorSlug.replace(/[%_]/g, '\\$&') + '%']);

    const members = r.rows.filter(row => groupKeyForRow(row) === key);
    if (!members.length) return res.status(404).json({ ok: false, error: 'group empty' });

    // Pattern label = the WORDS COMMON to every member's pre-dash title base (the real
    // shared pattern name), Title-Cased. Common-prefix is more robust than one member's T2.
    const memberBases = members.map(m => {
      let n = gStripVendorSuffix(m.title).split(' - ')[0].toLowerCase();
      for (const x of GROUP_NOISE) n = n.split(x).join(' ');
      return n.replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
    });
    // intersection of word-sets across members = the shared pattern words
    let patternBaseWords = memberBases.length ? memberBases[0].slice() : [];
    for (const wb of memberBases.slice(1)) {
      const set = new Set(wb);
      patternBaseWords = patternBaseWords.filter(w => set.has(w));
    }
    // if a single-member group, the pattern base = its T2 stripped title
    if (members.length === 1) {
      patternBaseWords = (gStripColors(gNormTitle(members[0].title)) || '').split(' ').filter(Boolean);
    }
    const labelBase = patternBaseWords.length
      ? patternBaseWords.join(' ')
      : (gStripColors(gNormTitle(members[0].title)) || members[0].pattern_name || '');
    const pattern = bestPatternLabel(members[0], labelBase);
    const patSetWords = new Set(patternBaseWords);

    const colorways = members
      .map(row => ({
        dw_sku: row.dw_sku,
        handle: row.handle,
        title: row.title,
        color_name: colorNameForRow(row, patSetWords) || row.dw_sku,
        color_hex: row.dominant_hex || row.background_hex || null,
        image_url: row.image_url || null,
        url: `/products/${row.handle}`
      }))
      .sort((a, b) => String(a.color_name).localeCompare(String(b.color_name)));

    // no-store: this response carries /products/ links whose ACTIVE+published gate is
    // checked at GENERATION time — caching would let an archived product serve a dead
    // link for the TTL window (contrarian finding, 2026-07-13).
    res.set('Cache-Control', 'no-store');
    res.json({
      ok: true,
      pattern_id: key,
      pattern,
      vendor: members[0].vendor || null,
      count: colorways.length,
      colorways
    });
  } catch (e) {
    console.error('colorways error', e);
    res.status(500).json({ ok: false, error: e.message });
  }
});

// Hue angle (0-360) from a hex; grayscale/low-sat or missing → 999 so it sorts last.
function hexHue(hex) {
  if (!hex) return 999;
  const m = String(hex).replace('#','').match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
  if (!m) return 999;
  const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/255;
  const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max - min;
  if (d < 0.06) return 999;                      // grayscale → bucket at the end
  let h;
  if (max === r) h = ((g - b) / d) % 6;
  else if (max === g) h = (b - r) / d + 2;
  else h = (r - g) / d + 4;
  h *= 60; if (h < 0) h += 360;
  return h;
}
// Hue family buckets (degrees) for the "Color" drill-down filter; 'neutral' = grayscale (999).
const HUE_BUCKETS = {
  red: [[345,360],[0,15]], orange: [[15,45]], yellow: [[45,70]], green: [[70,165]],
  teal: [[165,200]], blue: [[200,255]], purple: [[255,290]], pink: [[290,345]],
  neutral: [[999,999]]
};
function hueInBucket(h, name) {
  const b = HUE_BUCKETS[name]; if (!b) return true;
  return b.some(function (r) { return h >= r[0] && h <= r[1]; });
}
// First recognized design-style token from tags → for Style sort; none → 'zzz' (sorts last).
const STYLE_WORDS = ['abstract','animal','botanical','damask','floral','geometric','ikat','moroccan',
  'paisley','plaid','scenic','stripe','striped','toile','traditional','trellis','tropical','modern',
  'contemporary','art deco','mid-century','victorian','transitional','grasscloth','textured','solid'];
function styleOf(tags) {
  if (!tags) return 'zzz';
  const low = String(tags).toLowerCase();
  for (const w of STYLE_WORDS) if (low.indexOf(w) !== -1) return w;
  return 'zzz';
}

// GET /api/collection-grouped?handle=<collection-handle>&page=N&per=48
//   The WS-2 grouped-grid feed. Returns ONE tile per pattern_id for the named
//   collection, with a colorway count per tile, correct across pagination
//   because grouping happens server-side over the WHOLE collection before paging.
//
//   NOTE: the read-only mirror has no collection-membership table, so for LOCAL
//   testing this accepts ?vendor=<name> (group within a vendor) or ?all=1 (whole
//   active catalog) as the collection proxy. In PRODUCTION the theme passes the
//   collection's product handles (or this reads a collection_products join).
app.get('/api/collection-grouped', async (req, res) => {
  try {
    const vendor = req.query.vendor ? String(req.query.vendor).slice(0, 120) : null;
    const collHandle = req.query.collection_handle ? String(req.query.collection_handle).slice(0, 120) : null;
    const all = req.query.all === '1';
    const page = Math.max(1, parseInt(req.query.page, 10) || 1);
    const per = Math.max(1, Math.min(96, parseInt(req.query.per, 10) || 48));
    const sort = String(req.query.sort || 'newest').slice(0, 24);
    if (!vendor && !collHandle && !all) {
      return res.status(400).json({ ok: false, error: 'pass ?vendor=<name>, ?collection_handle=<handle>, or ?all=1' });
    }

    // VENDOR-FAMILY scope: a brand collection spans multiple vendor spellings
    // ("Designers Guild" / "Designer's Guild Europe" / "Designers Guild Europe").
    // Derive an alnum-only family key from the vendor or the collection handle
    // (drop trailing wallpaper/wallcovering/fabric), match the normalized vendor.
    function familyKey(s) {
      if (!s) return null;
      let w = String(s).toLowerCase().replace(/[-_]/g, ' ')
        .replace(/\b(wallpaper|wallcovering|wallcoverings|fabric|fabrics|collection)\b/g, ' ')
        .trim().split(/\s+/).filter(Boolean).slice(0, 2).join('');
      return w.replace(/[^a-z0-9]/g, '') || null;
    }
    const fam = familyKey(vendor) || familyKey(collHandle);

    const params = [];
    let where = `sp.status = 'ACTIVE' ${PUB_SQL} AND sp.title IS NOT NULL AND sp.handle IS NOT NULL AND sp.dw_sku IS NOT NULL AND sp.image_url IS NOT NULL`;
    if (fam && !all) {
      params.push(fam + '%');
      where += ` AND regexp_replace(lower(sp.vendor), '[^a-z0-9]', '', 'g') LIKE $${params.length}`;
    }

    const r = await pool.query(`
      SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.mfr_sku, sp.pattern_name, sp.tags,
             sp.image_url, sp.metafields, sp.synced_at, sce.color_family, sce.dominant_hex
      FROM shopify_products sp
      LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
      WHERE ${where}
      ORDER BY sp.synced_at DESC NULLS LAST
      ${all && !vendor ? 'LIMIT 8000' : ''}
    `, params);

    // group server-side over the FULL set → counts are correct regardless of paging
    const groups = new Map();
    let seq = 0;
    for (const row of r.rows) {
      const key = groupKeyForRow(row);
      let g = groups.get(key);
      if (!g) {
        const labelBase = gStripColors(gNormTitle(row.title)) || (row.pattern_name || '');
        g = {
          pattern_id: key,
          pattern: bestPatternLabel(row, labelBase),
          vendor: row.vendor || null,
          // representative tile = first (newest) member
          rep_handle: row.handle, rep_dw_sku: row.dw_sku, rep_image_url: row.image_url,
          count: 0, swatches: [],
          _seq: seq++,                              // newest-first insertion order
          _hue: hexHue(row.dominant_hex),           // for Color (Hue) sort
          _style: styleOf(row.tags)                 // for Style sort
        };
        groups.set(key, g);
      }
      g.count++;
      if (g.swatches.length < 6 && (row.dominant_hex)) g.swatches.push(row.dominant_hex);
    }

    const allGroups = [...groups.values()];
    let arr = allGroups;

    // FACETS — which Color/Style values actually have tiles in THIS collection, so the
    // front-end only renders populated filter chips (no dead tab that returns 0 results).
    // Computed over the UNFILTERED set so the chip list stays stable while drilling in.
    const styleCounts = {}, hueCounts = {};
    for (const g of allGroups) {
      if (g._style && g._style !== 'zzz') styleCounts[g._style] = (styleCounts[g._style] || 0) + 1;
      for (const hk of Object.keys(HUE_BUCKETS)) {
        if (hueInBucket(g._hue, hk)) { hueCounts[hk] = (hueCounts[hk] || 0) + 1; break; }
      }
    }
    const facets = {
      color: Object.keys(hueCounts).filter(k => hueCounts[k] > 0),
      style: Object.keys(styleCounts).filter(k => styleCounts[k] > 0)
    };

    // DRILL-DOWN FILTERS — the "Color" / "Style" chips reveal value sub-chips that filter here.
    const hueF = String(req.query.hue || '').toLowerCase().slice(0, 16);
    const styleF = String(req.query.style || '').toLowerCase().slice(0, 24);
    if (styleF) arr = arr.filter(g => g._style === styleF);
    if (hueF && HUE_BUCKETS[hueF]) arr = arr.filter(g => hueInBucket(g._hue, hueF));
    // SORT the grouped tiles (over the whole set) before paging, so order is stable across pages.
    const byPattern = (a, b) => String(a.pattern).localeCompare(String(b.pattern));
    const sorters = {
      newest:      (a, b) => a._seq - b._seq,
      colors_desc: (a, b) => b.count - a.count || byPattern(a, b),
      pattern_az:  byPattern,
      vendor_az:   (a, b) => String(a.vendor||'').localeCompare(String(b.vendor||'')) || byPattern(a, b),
      hue:         (a, b) => (a._hue - b._hue) || byPattern(a, b),       // grayscale (hue=999) sorts last
      style:       (a, b) => String(a._style).localeCompare(String(b._style)) || byPattern(a, b)
    };
    arr.sort(sorters[sort] || sorters.newest);
    const total_tiles = arr.length;
    const start = (page - 1) * per;
    const slice = arr.slice(start, start + per);

    // no-store: carries product_url links — see link-guarantee cache note on /api/colorways.
    res.set('Cache-Control', 'no-store');
    res.json({
      ok: true,
      collection: vendor ? { vendor } : { all: true },
      page, per,
      total_tiles,
      total_pages: Math.ceil(total_tiles / per),
      has_next: start + per < total_tiles,
      facets,
      tiles: slice.map(g => ({
        pattern_id: g.pattern_id,
        pattern: g.pattern,
        vendor: g.vendor,
        count: g.count,
        colors_label: g.count > 1 ? `${g.count} Colors` : '1 Color',
        image_url: g.rep_image_url,
        handle: g.rep_handle,
        dw_sku: g.rep_dw_sku,
        swatches: g.swatches,
        pattern_url: `/pages/pattern?pattern_id=${encodeURIComponent(g.pattern_id)}`,
        product_url: `/products/${g.rep_handle}`
      }))
    });
  } catch (e) {
    console.error('collection-grouped error', e);
    res.status(500).json({ ok: false, error: e.message });
  }
});

app.get('/api/similar', async (req, res) => {
  try {
    const dw_sku = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
    const handle = req.query.handle && SAFE_KEY.test(req.query.handle) ? req.query.handle : null;
    if (!dw_sku && !handle) {
      return res.status(400).json({ ok: false, error: 'pass ?dw_sku=… or ?handle=…' });
    }

    const source = await loadSource({ dw_sku, handle });
    if (!source) return res.status(404).json({ ok: false, error: 'source not found' });

    const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 12));
    // null status = unknown, treat as live (not archived) rather than false-flag it
    const sourceArchived = source.status != null && source.status !== 'ACTIVE';
    // CLIP when explicitly requested (?engine=clip) OR when SIMILAR_CLIP_DEFAULT is on
    // and the caller didn't force ?engine=tag. Same one-switch pattern as pairs.
    const engine = (req.query.engine === 'clip' || (SIMILAR_CLIP_DEFAULT && req.query.engine !== 'tag'))
      ? 'clip' : 'tag';

    // CLIP engine: color+texture-locked image-embedding nearest-neighbour, no tag dependency.
    let top = null, usedEngine = 'tag';
    if (engine === 'clip') {
      top = await clipSimilarTop(source, requestedK);   // null → service down / sku not embedded
      if (top) usedEngine = 'clip';
    }

    // Tag engine (default, and the fallback when CLIP misses). Keys on motif/material/era.
    if (!top) {
      if (!source.tags || parseTags(source.tags).length === 0) {
        return res.json({
          ok: true, engine: 'tag',
          source: { dw_sku: source.dw_sku, handle: source.handle, title: source.title, image_url: source.image_url, vendor: source.vendor, archived: sourceArchived },
          similar: [],
          reason: sourceArchived
            ? `source is ${source.status} (not a live product) — no similar designs`
            : 'no tags on source — cannot compute similar designs'
        });
      }
      const candidates = await loadSimilarCandidates(source, 1200);
      top = pickSimilar(source, candidates, requestedK);
    }

    // Palette dots + paint chips, same shape as /api/pairs for UI parity.
    const sourcePalette = paletteOf(source);
    const allHexes = sourcePalette.slice();
    for (const p of top) for (const h of p._palette) allHexes.push(h);
    const paintMap = await nearestPaintsBatch(pool, allHexes);

    // no-store: carries /products/ links — see link-guarantee cache note on /api/colorways.
    res.set('Cache-Control', 'no-store');
    res.json({
      ok: true,
      engine: usedEngine,
      source: {
        dw_sku: source.dw_sku, handle: source.handle, title: source.title,
        vendor: source.vendor, image_url: source.image_url, archived: sourceArchived,
        palette: paletteWithPaints(sourcePalette, paintMap)
      },
      // an empty strip on an archived/unpublished source is expected, not a fault
      ...(top.length === 0 && sourceArchived
        ? { reason: `source is ${source.status} (not a live product) — no similar designs` } : {}),
      similar: top.map(p => ({
        dw_sku: p.dw_sku, handle: p.handle, title: safeTitle(p), vendor: p.vendor,
        image_url: p.image_url, url: `/products/${p.handle}`,
        score: p._score, why: p._why, delta_e: p._dE, palette: paletteWithPaints(p._palette, paintMap)
      }))
    });
  } catch (e) {
    console.error('similar error', e);
    res.status(500).json({ ok: false, error: e.message });
  }
});

app.get('/api/pairs', async (req, res) => {
  try {
    const dw_sku  = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
    const handle  = req.query.handle && SAFE_KEY.test(req.query.handle) ? req.query.handle : null;
    const paletteParam = parsePaletteParam(req.query.palette);
    if (!dw_sku && !handle && paletteParam.length === 0) {
      return res.status(400).json({ ok: false, error: 'pass ?dw_sku=…, ?handle=…, or ?palette=#hex1,#hex2,…' });
    }

    // Synthetic source from palette param (used by wallco.ai design pages,
    // any external integration where the source isn't in shopify_products).
    let source;
    if (paletteParam.length) {
      source = {
        dw_sku: null,
        handle: null,
        title: String(req.query.title || 'External source').slice(0, 200),
        vendor: String(req.query.vendor || '').slice(0, 100),
        image_url: String(req.query.image_url || '').slice(0, 1000) || null,
        tags: null,
        pattern_name: null,
        dominant_hex: paletteParam[0],
        background_hex: paletteParam[1] || null,
        hex_codes: JSON.stringify(paletteParam),
        color_family: null
      };
    } else {
      source = await loadSource({ dw_sku, handle });
      if (!source) return res.status(404).json({ ok: false, error: 'source not found' });
    }

    // Bail only if we have NEITHER tags NOR palette — palette alone is enough
    // for the palette-overlap path to find coordinates.
    const sourceHasTags    = source.tags && parseTags(source.tags).length > 0;
    const sourceHasPalette = paletteOf(source).length > 0;
    if (!sourceHasTags && !sourceHasPalette) {
      return res.json({
        ok: true,
        source: { dw_sku: source.dw_sku, handle: source.handle, title: source.title, image_url: source.image_url, vendor: source.vendor },
        pairs: [],
        reason: 'no tags or palette on source — cannot compute pairings'
      });
    }

    // Limit query: caller can request up to 24 via ?limit=
    const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 24));
    const candidates = await loadCandidates(source, 1200);

    // Blend CLIP style-world sub-score when explicitly requested (?engine=clip) OR when
    // PAIRS_CLIP_DEFAULT is on for all callers — but ?engine=tag always force-disables.
    // clipPairwiseMap returns {} on any miss (service down / circuit open), so the blend
    // degrades to tag+palette and the response never depends on the service being up.
    let usedEngine = 'tag';
    let clipMap = null;
    const wantClip = req.query.engine === 'clip'
      || (PAIRS_CLIP_DEFAULT && req.query.engine !== 'tag');
    if (wantClip) {
      clipMap = await clipPairwiseMap(source, candidates);
      if (Object.keys(clipMap).length) usedEngine = 'clip-blend';
      else clipMap = null;
    }
    const top = pickPairs(source, candidates, requestedK, clipMap);

    // Collect every hex we need paint matches for: source palette + each pair palette
    const sourcePalette = paletteOf(source);
    const allHexes = sourcePalette.slice();
    for (const p of top) for (const h of p._palette) allHexes.push(h);
    const paintMap = await nearestPaintsBatch(pool, allHexes);

    // no-store: carries /products/ links — see link-guarantee cache note on /api/colorways.
    res.set('Cache-Control', 'no-store');
    res.json({
      ok: true,
      engine: usedEngine,
      source: {
        dw_sku: source.dw_sku,
        handle: source.handle,
        title: source.title,
        vendor: source.vendor,
        image_url: source.image_url,
        palette: paletteWithPaints(sourcePalette, paintMap)
      },
      pairs: top.map(p => ({
        dw_sku: p.dw_sku,
        handle: p.handle,
        title: safeTitle(p),
        vendor: p.vendor,
        image_url: p.image_url,
        url: `/products/${p.handle}`,
        score: p._score,
        why: p._why,
        delta_e: p._dE,
        palette: paletteWithPaints(p._palette, paintMap)
      }))
    });
  } catch (e) {
    console.error('pairs error', e);
    res.status(500).json({ ok: false, error: e.message });
  }
});

// ============================================================
//  /api/ai-coordinates — AI-generated coordinate designs
//  Returns 24 stripe + plaid concepts seeded from the source palette using
//  lib/ai-stripes.js. Three width tiers (2″, 6″, 12″) × multiple styles
//  (even, pinstripe, cabana, wavy, awning, hairline, block) + 8 plaids
//  (buffalo, gingham, tattersall, windowpane, madras, tartan, glen, box).
//  Three slots use the lightest+darkest palette pair (Steve's standing rule).
//  Seamless tiling is structural — every variant uses SVG <pattern>.
//  Client can slide the repeat width via ?tile_in=N (12-48 inches); each
//  variant snaps tile_in DOWN to its pitch so the repeat always fits.
// ============================================================

app.get('/api/ai-coordinates', async (req, res) => {
  try {
    const dw_sku  = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
    const handle  = req.query.handle && SAFE_KEY.test(req.query.handle) ? req.query.handle : null;
    const paletteParam = parsePaletteParam(req.query.palette);
    if (!dw_sku && !handle && paletteParam.length === 0) {
      return res.status(400).json({ ok: false, error: 'pass dw_sku, handle, or palette' });
    }
    const tile_in = Math.max(2, Math.min(60, parseFloat(req.query.tile_in) || 24));
    // Honor ?limit= the same way /api/pairs does (default 6, hard cap 24) so the
    // Auto Generated grid renders exactly what the caller asks for — without this,
    // generateCoordinates() always returned its full set and the client showed 24.
    const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 6));

    let source, palette;
    if (paletteParam.length) {
      source = { dw_sku: null, handle: null,
        title: String(req.query.title || 'External source').slice(0, 200),
        image_url: String(req.query.image_url || '').slice(0, 1000) || null };
      palette = paletteParam;
    } else {
      source = await loadSource({ dw_sku, handle });
      if (!source) return res.status(404).json({ ok: false, error: 'source not found' });
      palette = paletteOf(source);
    }
    if (!palette.length) return res.json({ ok: true, source: { dw_sku: source.dw_sku, handle: source.handle }, items: [] });

    const items = generateCoordinates({ palette, tile_in }).slice(0, requestedK);

    res.set('Cache-Control', 'public, max-age=300');
    res.json({
      ok: true,
      tile_in: tile_in,
      source: {
        dw_sku: source.dw_sku,
        handle: source.handle,
        title: source.title,
        image_url: source.image_url,
        palette: palette
      },
      items: items
    });
  } catch (e) {
    console.error('ai-coordinates error', e);
    res.status(500).json({ ok: false, error: e.message });
  }
});

// Render ONE pattern from a custom user-edited spec. Used by the Wix-style
// pattern editor in the wallco.ai admin UI. Accepts:
//   GET  /api/ai-coordinates/render?motif=stripe&style=even&pitch_in=6&colors=#000,#fff&tile_in=24
//   POST /api/ai-coordinates/render  body: { motif, style, pitch_in, colors:[], tile_in, pinstripe? }
app.use('/api/ai-coordinates/render', express.json({ limit: '8kb' }));
function handleRender(req, res) {
  try {
    const src = (req.method === 'POST' ? req.body : req.query) || {};
    const colors = Array.isArray(src.colors)
      ? src.colors
      : String(src.colors || '').split(',').map(s => s.trim()).filter(Boolean);
    const spec = {
      motif: String(src.motif || 'stripe').slice(0, 20),
      style: String(src.style || 'even').slice(0, 24),
      pitch_in: parseFloat(src.pitch_in) || 6,
      colors: colors.filter(h => /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(h))
                    .map(h => h.startsWith('#') ? h : '#' + h)
                    .slice(0, 5),
      pinstripe: src.pinstripe || undefined,
      label: src.label ? String(src.label).slice(0, 120) : undefined
    };
    const tile_in = parseFloat(src.tile_in) || 24;
    const rendered = renderCustom(spec, tile_in);
    res.set('Cache-Control', 'no-store');
    res.json({ ok: true, ...rendered });
  } catch (e) {
    console.error('ai-coordinates/render error', e);
    res.status(500).json({ ok: false, error: e.message });
  }
}
app.get('/api/ai-coordinates/render', handleRender);
app.post('/api/ai-coordinates/render', handleRender);

app.listen(PORT, () => {
  console.log(`dw-pairs-well listening on :${PORT}`);
});