← back to Big Red

server.js

802 lines

'use strict';

require('dotenv').config();
const express = require('express');
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const { WebSocketServer } = require('ws');
const QRCode = require('qrcode');
const multer = require('multer');
const { Pool } = require('pg');
const Anthropic = require('@anthropic-ai/sdk');

const PORT = parseInt(process.env.PORT || '9936', 10);
const ELEVEN_KEY = process.env.ELEVENLABS_API_KEY || '';
const ELEVEN_VOICE = process.env.ELEVENLABS_VOICE_ID || 'Xa9qV4wNbvSkdUWsYLzq';
const LAN_HOST = process.env.LAN_HOST || detectLanHost();

// DW intelligence — only loaded if DATABASE_URL is set so the rest of Big Red
// keeps working in environments without dw_unified (e.g. when deploying the
// avatar app to a friend's box for general chat).
const DW_DB_URL = process.env.DATABASE_URL || '';
const SHOPIFY_STORE = process.env.SHOPIFY_STORE || '';
const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
const SHOPIFY_API_VERSION = process.env.SHOPIFY_API_VERSION || '2024-10';
const dwPool = DW_DB_URL ? new Pool({ connectionString: DW_DB_URL, max: 4, idleTimeoutMillis: 20_000 }) : null;
if (dwPool) {
  dwPool.on('error', (err) => console.error('[dw pool]', err.message));
}

// Tracks the last time each SKU was verified live against Shopify.
// Lets Big Red answer "is this price still good?" — anything within 24h is
// trusted, anything older triggers a fresh Shopify hit.
const PRICE_LOG = path.join(__dirname, 'data', 'price-verifications.json');
fs.mkdirSync(path.dirname(PRICE_LOG), { recursive: true });
function loadPriceLog() {
  try { return JSON.parse(fs.readFileSync(PRICE_LOG, 'utf8')); } catch { return {}; }
}
function savePriceLog(d) {
  try { fs.writeFileSync(PRICE_LOG, JSON.stringify(d, null, 2)); } catch (e) { console.error('[price-log]', e.message); }
}
function stampVerification(sku, price, source) {
  if (!sku) return;
  const log = loadPriceLog();
  log[sku] = { price, source, verified_at: new Date().toISOString() };
  savePriceLog(log);
}
function getVerification(sku) {
  const log = loadPriceLog();
  return log[sku] || null;
}

// SKU detection — DW family prefixes (DWKK/DWSC/DWSW/DWWD/DWQW/DWJS/...).
// Returns the first match in upper-case so it can be queried as canonical.
const DW_SKU_RE = /\bDW[A-Z]{2}-?\d{4,7}\b/i;
function detectSku(text) {
  if (typeof text !== 'string') return null;
  const m = text.match(DW_SKU_RE);
  return m ? m[0].toUpperCase().replace(/(DW[A-Z]{2})(\d)/, '$1-$2') : null;
}

// Per-catalog fallback queries — each returns a normalized row when the SKU
// is in that table. Tried in order after shopify_products misses. Schemas
// vary widely across catalogs (some have title, others have pattern_name +
// color_name; some have price_retail, others retail_price) so each catalog
// gets its own SELECT with hand-rolled normalization.
const SISTER_CATALOG_QUERIES = [
  {
    source: 'scalamandre_pillows',
    sql: `SELECT
      dw_sku AS sku,
      shopify_product_id::text AS shopify_id,
      NULL::text AS handle,
      pattern_name || COALESCE(' · ' || color_name, '') AS title,
      'Scalamandre'::text AS vendor,
      CASE WHEN discontinued THEN 'archived' WHEN in_stock THEN 'active' ELSE 'inactive' END AS status,
      image_url,
      product_url,
      price_retail::float AS retail_price,
      last_scraped AS synced_at
    FROM scalamandre_pillows WHERE dw_sku = $1 LIMIT 1`,
  },
  {
    source: 'scalamandre_catalog',
    sql: `SELECT
      dw_sku AS sku,
      shopify_product_id::text AS shopify_id,
      NULL::text AS handle,
      pattern_name || COALESCE(' · ' || color_name, '') AS title,
      'Scalamandre'::text AS vendor,
      CASE WHEN discontinued THEN 'archived' WHEN in_stock THEN 'active' ELSE 'inactive' END AS status,
      image_url,
      product_url,
      price_retail::float AS retail_price,
      last_scraped AS synced_at
    FROM scalamandre_catalog WHERE dw_sku = $1 LIMIT 1`,
  },
  {
    source: 'kravet_catalog',
    sql: `SELECT
      dw_sku AS sku,
      NULL::text AS shopify_id,
      NULL::text AS handle,
      pattern_name || COALESCE(' · ' || color_name, '') AS title,
      'Kravet'::text AS vendor,
      COALESCE(status, 'unknown') AS status,
      image_url,
      NULL::text AS product_url,
      price_retail::float AS retail_price,
      last_scraped AS synced_at
    FROM kravet_catalog WHERE dw_sku = $1 LIMIT 1`,
  },
  {
    source: 'schumacher_catalog',
    sql: `SELECT
      dw_sku AS sku,
      NULL::text AS shopify_id,
      NULL::text AS handle,
      pattern_name || COALESCE(' · ' || color_name, '') AS title,
      'Schumacher'::text AS vendor,
      'unknown'::text AS status,
      image_url,
      NULL::text AS product_url,
      retail_price::float AS retail_price,
      price_fetched_at AS synced_at
    FROM schumacher_catalog WHERE dw_sku = $1 LIMIT 1`,
  },
  {
    source: 'sisterparish_catalog',
    sql: `SELECT
      handle AS sku,
      shopify_product_id::text AS shopify_id,
      shopify_handle AS handle,
      title,
      vendor,
      'unknown'::text AS status,
      primary_image AS image_url,
      source_url AS product_url,
      NULLIF(variants->0->>'dw_retail', '')::float AS retail_price,
      updated_at AS synced_at
    FROM sisterparish_catalog
    WHERE handle = $1 OR shopify_handle = $1 LIMIT 1`,
  },
];

async function lookupSkuInPg(sku) {
  if (!dwPool) return null;
  // 1. Primary: shopify_products (the master synced table). Most DW SKUs
  //    that have been synced to Shopify live here with rich metadata.
  try {
    const r = await dwPool.query(
      `SELECT shopify_id, handle, title, vendor, status, image_url,
              created_at_shopify, updated_at_shopify, synced_at, tags,
              'shopify_products'::text AS _source
         FROM shopify_products
        WHERE sku = $1 OR variant_sku = $1 OR dw_sku = $1
        LIMIT 1`,
      [sku]
    );
    if (r.rows[0]) return r.rows[0];
  } catch (e) {
    console.error('[dw pg/shopify_products]', e.message);
  }
  // 2. Fallback: sister catalogs in priority order. The DWSC-700* pillows
  //    Steve worked through earlier in the day live in scalamandre_pillows,
  //    NOT in shopify_products — without this fallback the lookup returns
  //    null and Big Red has no facts to phrase from.
  for (const q of SISTER_CATALOG_QUERIES) {
    try {
      const r = await dwPool.query(q.sql, [sku]);
      if (r.rows[0]) {
        return { ...r.rows[0], _source: q.source };
      }
    } catch (e) {
      // A single bad catalog (e.g. schema drift, missing column) shouldn't
      // kill the whole fallback chain — log and continue.
      console.error(`[dw pg/${q.source}]`, e.message);
    }
  }
  return null;
}

// In-memory cache for live Shopify SKU lookups. 60s TTL — long enough to
// absorb a chatty back-and-forth about the same SKU within a single turn
// without re-hammering Shopify Admin GraphQL, short enough that a real
// inventory or price change is reflected within a minute. Map grows boundedly
// because we sweep entries older than TTL*4 every 5 min (cap insurance).
const SHOPIFY_TTL_MS = 60_000;
const SHOPIFY_CACHE = new Map(); // sku -> { result, fetched_at }
setInterval(() => {
  const cutoff = Date.now() - SHOPIFY_TTL_MS * 4;
  let dropped = 0;
  for (const [k, v] of SHOPIFY_CACHE.entries()) {
    if (v.fetched_at < cutoff) { SHOPIFY_CACHE.delete(k); dropped++; }
  }
  if (dropped > 0) console.log(`[shopify-cache] swept ${dropped} stale entries`);
}, 5 * 60 * 1000).unref();

async function lookupSkuLive(sku) {
  if (!SHOPIFY_STORE || !SHOPIFY_TOKEN) return null;
  const cached = SHOPIFY_CACHE.get(sku);
  if (cached && (Date.now() - cached.fetched_at) < SHOPIFY_TTL_MS) {
    return cached.result ? { ...cached.result, _cache_age_ms: Date.now() - cached.fetched_at } : null;
  }
  const query = `{
    products(first: 1, query: "sku:${sku}") {
      edges { node {
        id legacyResourceId handle title vendor status onlineStoreUrl
        featuredImage { url }
        variants(first: 1) { edges { node {
          sku price compareAtPrice inventoryQuantity availableForSale
        }}}
      }}
    }
  }`;
  try {
    const r = await fetch(`https://${SHOPIFY_STORE}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, {
      method: 'POST',
      headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN, 'Content-Type': 'application/json' },
      body: JSON.stringify({ query }),
    });
    if (!r.ok) { console.error('[shopify]', r.status, await r.text().catch(()=>'')); return null; }
    const j = await r.json();
    const node = j?.data?.products?.edges?.[0]?.node;
    if (!node) {
      // Cache a null too — repeated "no-such-SKU" queries within a minute
      // shouldn't hammer Shopify any more than valid queries should.
      SHOPIFY_CACHE.set(sku, { result: null, fetched_at: Date.now() });
      return null;
    }
    const v = node.variants?.edges?.[0]?.node || {};
    const result = {
      shopify_id: node.legacyResourceId || node.id,
      handle: node.handle,
      title: node.title,
      vendor: node.vendor,
      status: node.status,
      image: node.featuredImage?.url || '',
      online_store_url: node.onlineStoreUrl || `https://${SHOPIFY_STORE.replace('.myshopify.com', '')}/products/${node.handle}`,
      price: v.price ? parseFloat(v.price) : null,
      compare_at_price: v.compareAtPrice ? parseFloat(v.compareAtPrice) : null,
      inventory: v.inventoryQuantity ?? null,
      available: v.availableForSale ?? null,
    };
    SHOPIFY_CACHE.set(sku, { result, fetched_at: Date.now() });
    return result;
  } catch (e) {
    console.error('[shopify lookup]', e.message);
    return null;
  }
}

// Resolves a SKU end-to-end: PG snapshot for context + live Shopify for price,
// stamping the verification log so subsequent calls know the freshness.
async function resolveDwSku(sku) {
  if (!sku) return null;
  const [pgRow, live] = await Promise.all([lookupSkuInPg(sku), lookupSkuLive(sku)]);
  if (live && live.price != null) {
    stampVerification(sku, live.price, 'shopify-admin-live');
  }
  return {
    sku,
    pg: pgRow,
    live,
    verification: getVerification(sku),
  };
}

// Frames go to /tmp (not ~/Projects/big-red/tmp) — keeping them outside any
// directory tree that contains a CLAUDE.md cuts claude-CLI cold-start from
// ~3min to ~25s because --add-dir won't trigger memory auto-discovery.
const TMP = path.join(os.tmpdir(), 'big-red-frames');
fs.mkdirSync(TMP, { recursive: true });

function detectLanHost() {
  const ifaces = os.networkInterfaces();
  for (const name of Object.keys(ifaces)) {
    for (const i of ifaces[name] || []) {
      if (i.family === 'IPv4' && !i.internal) return i.address;
    }
  }
  return 'localhost';
}

const app = express();
app.use(express.json({ limit: '20mb' }));
app.get('/phone', (req, res) => res.sendFile(path.join(__dirname, 'public', 'phone.html')));
// 404-guard: never serve editor/snapshot backup files even if they leak into public/
app.use((req, res, next) => {
  if (/\.(bak|pre-[\w.-]+|orig|swp|swo|tmp)(\?|$|\/)/i.test(req.path) ||
      /\.bak\.[\w.-]+(\?|$|\/)/i.test(req.path) ||
      /\.pre-[\w.-]+(\?|$|\/)/i.test(req.path)) {
    return res.status(404).end();
  }
  next();
});
app.use(express.static(path.join(__dirname, 'public')));

const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });

// ---------- pairing ----------
const pairings = new Map(); // code -> { desktopWs, phoneWs, createdAt }

function newPairCode() {
  return crypto.randomBytes(4).toString('hex');
}

app.get('/api/pair/new', async (req, res) => {
  const code = newPairCode();
  pairings.set(code, { desktopWs: null, phoneWs: null, createdAt: Date.now() });
  const phoneUrl = `http://${LAN_HOST}:${PORT}/phone?code=${code}`;
  const qrDataUrl = await QRCode.toDataURL(phoneUrl, { width: 320, margin: 1 });
  res.json({ code, phoneUrl, qrDataUrl, lanHost: LAN_HOST, port: PORT });
});

// ---------- Anthropic SDK helpers ----------
// Was: shelling out to the `claude` CLI via spawn(). That broke in prod
// (Kamatera) because /root/.local/bin/claude doesn't exist there, so every
// /api/chat request 500'd with ENOENT. Now we call the Anthropic Messages API
// directly via @anthropic-ai/sdk. Same signature as the old runClaude so all
// three callsites (chat, analyze, warm-up) keep working unchanged.
// Backend switch. Default 'anthropic' (Messages API) keeps the original
// behaviour; set LLM_BACKEND=ollama to answer from a local/self-hosted Ollama
// model instead (no metered API spend, no Anthropic-credit dependency). On
// Kamatera prod we point OLLAMA_URL at the Mac2 Studio's Ollama exposed over
// the tailnet (tailscale serve --tcp). Anything else falls through to Anthropic.
const LLM_BACKEND = (process.env.LLM_BACKEND || 'anthropic').toLowerCase();
const OLLAMA_URL = (process.env.OLLAMA_URL || 'http://127.0.0.1:11434').replace(/\/+$/, '');
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';
const OLLAMA_VISION_MODEL = process.env.OLLAMA_VISION_MODEL || 'qwen2.5vl:7b';

const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY || '';
if (LLM_BACKEND === 'anthropic' && !ANTHROPIC_KEY) {
  console.warn('[big-red] WARNING: ANTHROPIC_API_KEY not set — /api/chat and /api/analyze will 500. Add it to .env or set LLM_BACKEND=ollama.');
}
if (LLM_BACKEND === 'ollama') {
  console.log(`[big-red] LLM backend: ollama (${OLLAMA_URL}, text=${OLLAMA_MODEL}, vision=${OLLAMA_VISION_MODEL})`);
}
const anthropic = ANTHROPIC_KEY ? new Anthropic({ apiKey: ANTHROPIC_KEY }) : null;

// Local/self-hosted backend via Ollama's OpenAI-compatible endpoint. Same
// contract as runClaude: takes the assembled prompt, returns the reply text.
// Vision requests (imagePath set) route to a vision-capable model; text chat
// uses the main text model. Thinking models (qwen3) may wrap their reasoning in
// <think>…</think> — we strip it so clients only see the final answer.
async function runOllama(prompt, { imagePath = null, timeoutMs = 240_000 } = {}) {
  const model = imagePath ? OLLAMA_VISION_MODEL : OLLAMA_MODEL;
  let content;
  if (imagePath) {
    const buf = fs.readFileSync(imagePath);
    const ext = path.extname(imagePath).slice(1).toLowerCase();
    const media_type =
      ext === 'png' ? 'image/png' :
      ext === 'webp' ? 'image/webp' :
      ext === 'gif' ? 'image/gif' :
      'image/jpeg';
    content = [
      { type: 'text', text: prompt },
      { type: 'image_url', image_url: { url: `data:${media_type};base64,${buf.toString('base64')}` } },
    ];
  } else {
    content = prompt;
  }
  const ac = new AbortController();
  const t = setTimeout(() => ac.abort(), timeoutMs);
  try {
    const r = await fetch(`${OLLAMA_URL}/v1/chat/completions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ model, max_tokens: 1024, stream: false, messages: [{ role: 'user', content }] }),
      signal: ac.signal,
    });
    if (!r.ok) {
      const body = await r.text().catch(() => '');
      throw new Error(`Ollama ${r.status}: ${body.slice(0, 200)}`);
    }
    const d = await r.json();
    let text = d?.choices?.[0]?.message?.content || '';
    text = text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
    return text;
  } catch (e) {
    if (e.name === 'AbortError') throw new Error('Ollama request timed out');
    throw e;
  } finally {
    clearTimeout(t);
  }
}

// Map the legacy `model: 'haiku'` shorthand (used by old CLI callers) to a
// current API model ID. Anything else is passed through verbatim so callers
// can ask for a specific snapshot.
function resolveModelId(m) {
  if (!m || m === 'haiku') return 'claude-haiku-4-5-20251001';
  if (m === 'sonnet') return 'claude-sonnet-4-5-20251022';
  if (m === 'opus') return 'claude-opus-4-7-20251022';
  return m;
}

function runClaude(prompt, { imagePath = null, model = 'haiku', timeoutMs = 240_000 } = {}) {
  // Local backend takes over when selected — same return contract (reply text).
  if (LLM_BACKEND === 'ollama') {
    return runOllama(prompt, { imagePath, timeoutMs });
  }
  if (!anthropic) {
    return Promise.reject(new Error('ANTHROPIC_API_KEY missing — cannot call Anthropic API'));
  }

  // Build the user message content. Plain text by default; if an image path
  // is provided, base64-encode it and send a multi-part content array so the
  // model can see the image (replaces the old `@path` CLI mechanic).
  let content;
  if (imagePath) {
    const buf = fs.readFileSync(imagePath);
    const ext = path.extname(imagePath).slice(1).toLowerCase();
    const media_type =
      ext === 'png' ? 'image/png' :
      ext === 'webp' ? 'image/webp' :
      ext === 'gif' ? 'image/gif' :
      'image/jpeg';
    content = [
      { type: 'image', source: { type: 'base64', media_type, data: buf.toString('base64') } },
      { type: 'text', text: prompt },
    ];
  } else {
    content = prompt;
  }

  const call = anthropic.messages.create({
    model: resolveModelId(model),
    max_tokens: 1024,
    messages: [{ role: 'user', content }],
  });

  // Preserve the old timeout semantics — reject if the API call hasn't
  // returned by timeoutMs. The SDK has its own timeout but this gives us
  // the exact ceiling per-callsite that the old CLI runner enforced.
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => reject(new Error('Anthropic API timed out')), timeoutMs);
    call.then((resp) => {
      clearTimeout(t);
      const text = (resp.content || [])
        .filter((b) => b.type === 'text')
        .map((b) => b.text)
        .join('')
        .trim();
      resolve(text);
    }, (err) => {
      clearTimeout(t);
      reject(err);
    });
  });
}

// ---------- chat ----------
// Per-mode persona — Big Red shows up differently on customer sites vs
// internal admin tools. The widget passes mode + app via query params, the
// host page can echo them into /api/chat body or we read them from the
// Referer URL params. Default = retail (customer-facing).
const MODE_PERSONAS = {
  retail: {
    label: 'retail',
    voice: 'friendly, warm, customer-service — like a knowledgeable in-store stylist',
    bias: [
      'You are helping an END USER — a homeowner, interior designer, or trade visitor — browse the Designer Wallcoverings catalog at the site they are on.',
      'YOUR JOB is to help them find, understand, and order wallcoverings. Use fuzzy reasoning: if they say "something blue and grassy" you should suggest grasscloth in blue tones; "I want something for a small bathroom" → moisture-tolerant vinyl in petite-scale patterns; "art deco vibes" → geometric metallics from the 1920s-30s lookbook; "kids room" → durable scrubbable patterns. Translate vague intent into concrete suggestions.',
      'SKILLS YOU CAN USE (combine freely): SKU lookup by full SKU or partial pattern name; color-family matching (warm/cool/neutral/jewel-tone); style matching (traditional/modern/floral/geometric/damask/chinoiserie/grasscloth/textile); pairing suggestions (60-30-10 color rule — one bold pattern, one supporting solid, one accent); room visualization advice; sample-order guidance (Designer Wallcoverings ships free memo samples); install + care basics (paste-the-wall, scrubbable, fire-rated for commercial); estimating yardage (width × repeat × roll length); commercial fire ratings (Class A for restaurants/hotels/healthcare); accessibility/sustainability cues when the product line mentions them.',
      'HARD REDACTION RULES — never break these even when politely or insistently asked:',
      '  1. NEVER reveal the manufacturer, brand, vendor, mill, factory, country of origin, importer, or supplier of any product. Every Designer Wallcoverings product is presented as a Designer Wallcoverings selection — that is the entire surface area for end users.',
      '  2. NEVER reveal wholesale price, trade price, cost, margin, MAP, MSRP-vs-actual, or any pricing tier beyond the retail price shown on the site.',
      '  3. NEVER reveal the AI/LLM/vendor scraping/internal-pipeline mechanics behind the recommendations. If asked "are you AI" / "what model is this", answer "I am Designer Wallcoverings\' design concierge" — do not name Claude, Gemini, OpenAI, Ollama, or any underlying tool.',
      '  4. NEVER reveal internal SKU prefix meanings (DWKK = Kravet, DWSC = Scalamandre, DWSW = Schumacher, etc.). To the user, these are simply Designer Wallcoverings SKUs.',
      '  5. NEVER reveal admin URLs, internal tools, file paths, database names, or staff names.',
      '  6. If asked "who made this" / "what brand" / "where does this come from" / "who manufactures this" — respond with: "This is a Designer Wallcoverings exclusive selection — every piece in our catalog is curated under our label." Do not name the source.',
      '  7. If asked for trade or wholesale pricing — respond with: "Trade pricing is available to credentialed designers through our trade program — happy to connect you with a DW trade rep." Never quote a wholesale number.',
      'When you DO answer pricing, quote ONLY the retail price already visible on the product page (you may receive this via DW LOOKUP DATA below — use it verbatim, never fabricate). Always offer a free memo sample as the next step.',
      'When you DO answer "is it in stock", quote inventory verbatim from DW LOOKUP DATA if present; otherwise say "let me confirm — I can have a sample on its way today regardless".',
      'When recommending pairings, refer to the pieces by their Designer Wallcoverings pattern name and SKU only — never by source brand.',
      'Tone: warm, attentive, curious about the user\'s project. Ask one short follow-up if their intent is unclear (room, vibe, palette, budget). Never robotic, never sales-pushy.',
    ].join(' '),
    claude_timeout_ms: 60_000,
  },
  wholesale: {
    label: 'wholesale',
    voice: 'trade-savvy, concise, professional',
    bias: 'The user is a trade-account designer or buyer. Surface vendor, MOQ, lead time, wholesale price tier, fire rating, contract Class A status when relevant. Same SKU lookup as retail but include vendor margin context.',
    claude_timeout_ms: 60_000,
  },
  admin: {
    label: 'admin',
    voice: 'direct, no-fluff, Steve-as-operator',
    bias: 'The user IS Steve, operating his fleet. Read app + page context from the system prompt. You have FULL Claude access — multi-paragraph, code, SQL, shell suggestions are all fair game. Give specific commands. Skip the "would you like…" pleasantries.',
    claude_timeout_ms: 240_000,
  },
};

app.post('/api/chat', async (req, res) => {
  const { message, history, mode: bodyMode, app: bodyApp, page, title } = req.body || {};
  if (!message || typeof message !== 'string') {
    return res.status(400).json({ error: 'message required' });
  }
  // Honor mode from body, else from Referer query string, else retail.
  let mode = bodyMode || 'retail';
  let appSlug = bodyApp || '';
  try {
    if (!bodyMode && req.headers.referer) {
      const refUrl = new URL(req.headers.referer);
      mode = refUrl.searchParams.get('mode') || mode;
      appSlug = refUrl.searchParams.get('app') || appSlug;
    }
  } catch {}
  if (!MODE_PERSONAS[mode]) mode = 'retail';
  const persona = MODE_PERSONAS[mode];
  try {
    // DW intelligence — if the message mentions a SKU, enrich the prompt
    // with live Shopify + dw_unified data BEFORE handing to Claude. The model
    // doesn't have to fetch anything — it just gets the facts and phrases them.
    let dwContext = '';
    let dwLookup = null;
    const sku = detectSku(message);
    if (sku) {
      dwLookup = await resolveDwSku(sku);
      if (dwLookup) {
        // Retail mode is the customer-facing surface — the LLM must NEVER see
        // the vendor name (it cannot reveal what it doesn't know). Wholesale +
        // admin still get full vendor context for trade/operator workflows.
        const redactVendor = mode === 'retail';
        const brandLabel = redactVendor ? 'Designer Wallcoverings' : null;
        const lines = [];
        lines.push(`Looked up DW SKU ${sku}:`);
        if (dwLookup.live) {
          const lv = dwLookup.live;
          const price = lv.price != null ? `$${lv.price.toFixed(2)}` : 'unknown';
          const label = redactVendor ? brandLabel : lv.vendor;
          lines.push(`  Live Shopify (just verified): "${lv.title}" by ${label}, ${price}, status=${lv.status}, inventory=${lv.inventory ?? '?'}, available=${lv.available ?? '?'}`);
          if (lv.online_store_url) lines.push(`  URL: ${lv.online_store_url}`);
        } else if (dwLookup.pg) {
          const pg = dwLookup.pg;
          const label = redactVendor ? brandLabel : pg.vendor;
          lines.push(`  PG snapshot (no live Shopify response): "${pg.title}" by ${label}, status=${pg.status}, last synced ${pg.synced_at}`);
        } else {
          lines.push(`  NOT FOUND in shopify_products OR live Shopify search. SKU may be retired, mistyped, or in a different catalog.`);
        }
        if (dwLookup.verification && !redactVendor) {
          // Verification source names internal scrapers / vendor URLs — admin/wholesale only.
          const v = dwLookup.verification;
          const ageHours = (Date.now() - new Date(v.verified_at).getTime()) / 3_600_000;
          lines.push(`  Price last verified ${ageHours.toFixed(1)}h ago via ${v.source} at $${v.price?.toFixed?.(2) ?? v.price}.`);
        } else if (dwLookup.verification && redactVendor) {
          const v = dwLookup.verification;
          const ageHours = (Date.now() - new Date(v.verified_at).getTime()) / 3_600_000;
          lines.push(`  Price last confirmed ${ageHours.toFixed(1)}h ago at $${v.price?.toFixed?.(2) ?? v.price}.`);
        }
        const guard = redactVendor
          ? '[DW LOOKUP DATA — facts only. The brand label is "Designer Wallcoverings" — never substitute any other brand name. Do NOT invent numbers. Do NOT invent vendor/manufacturer info.]'
          : '[DW LOOKUP DATA — these are facts; phrase them naturally for Steve, do NOT invent numbers]';
        dwContext = '\n' + guard + '\n' + lines.join('\n') + '\n';
      }
    }

    const ctx = (history || [])
      .slice(-10)
      .map((m) => `${m.role === 'user' ? 'User' : 'BigRed'}: ${m.content}`)
      .join('\n');
    const appLine = appSlug ? `Embedded in app: ${appSlug}` : '';
    const pageLine = page ? `Page: ${page}${title ? ' — ' + title : ''}` : '';
    const prompt = [
      `You are "Big Red", a chat companion for Steve (founder, Designer Wallcoverings).`,
      `MODE: ${persona.label} — voice ${persona.voice}.`,
      `Persona bias: ${persona.bias}`,
      appLine,
      pageLine,
      'You know the DW catalog — SKUs prefixed DWKK/DWSC/DWSW/DWWD/DWQW/DWJS/etc. map to live Shopify products. When a SKU is mentioned, you may have been given live lookup data below — use it verbatim, never fabricate prices.',
      mode === 'admin'
        ? 'Reply directly and densely — Steve is operating, not chatting. SQL snippets, shell commands, and code are welcome. Skip the conversational throat-clearing.'
        : 'Reply conversationally in 1-3 short sentences unless asked for detail. Plain prose only — no markdown, no lists, no code fences. The reply will be spoken aloud.',
      ctx ? `\nConversation so far:\n${ctx}` : '',
      dwContext,
      `\nUser: ${message}`,
      '\nBigRed:'
    ].filter(Boolean).join('\n');
    const reply = await runClaude(prompt, { timeoutMs: persona.claude_timeout_ms });
    res.json({ reply, dw: dwLookup, mode, app: appSlug });
  } catch (e) {
    res.status(500).json({ error: String(e.message || e) });
  }
});

// Direct SKU lookup endpoint — useful from the widget UI or a CLI test.
app.get('/api/dw/sku/:sku', async (req, res) => {
  const sku = String(req.params.sku || '').toUpperCase();
  if (!DW_SKU_RE.test(sku)) {
    return res.status(400).json({ error: 'not a DW SKU format', sku });
  }
  try {
    const result = await resolveDwSku(sku);
    res.json(result);
  } catch (e) {
    res.status(500).json({ error: String(e.message || e) });
  }
});

// ---------- frame analysis ----------
app.post('/api/analyze', upload.single('frame'), async (req, res) => {
  try {
    let buf = null;
    let prompt = req.body?.prompt || 'Describe what you see in this image. Be concise — 1 to 3 sentences. Plain prose, no lists.';
    if (req.file?.buffer) {
      buf = req.file.buffer;
    } else if (req.body?.dataUrl && typeof req.body.dataUrl === 'string') {
      const m = req.body.dataUrl.match(/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/);
      if (!m) return res.status(400).json({ error: 'bad dataUrl' });
      buf = Buffer.from(m[2], 'base64');
    }
    if (!buf) return res.status(400).json({ error: 'no image provided' });

    const fname = `frame-${Date.now()}-${crypto.randomBytes(3).toString('hex')}.jpg`;
    const fpath = path.join(TMP, fname);
    fs.writeFileSync(fpath, buf);

    const reply = await runClaude(prompt, { imagePath: fpath, timeoutMs: 300_000 });

    // Best-effort cleanup after 60s so old frames don't pile up
    setTimeout(() => { fs.unlink(fpath, () => {}); }, 60_000);

    res.json({ reply });
  } catch (e) {
    res.status(500).json({ error: String(e.message || e) });
  }
});

// ---------- avatar capture (uses phone-cam frame as Steve's face) ----------
const AVATAR_DIR = path.join(__dirname, 'public', 'avatar');
fs.mkdirSync(AVATAR_DIR, { recursive: true });
const AVATAR_PATH = path.join(AVATAR_DIR, 'steve.jpg');

app.get('/api/avatar', (req, res) => {
  if (fs.existsSync(AVATAR_PATH)) {
    res.json({ exists: true, url: `/avatar/steve.jpg?t=${fs.statSync(AVATAR_PATH).mtimeMs}` });
  } else {
    res.json({ exists: false });
  }
});

app.post('/api/avatar/capture', upload.single('frame'), (req, res) => {
  try {
    let buf = null;
    if (req.file?.buffer) buf = req.file.buffer;
    else if (req.body?.dataUrl) {
      const m = req.body.dataUrl.match(/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/);
      if (m) buf = Buffer.from(m[2], 'base64');
    }
    if (!buf) return res.status(400).json({ error: 'no image' });
    fs.writeFileSync(AVATAR_PATH, buf);
    res.json({ ok: true, url: `/avatar/steve.jpg?t=${Date.now()}` });
  } catch (e) {
    res.status(500).json({ error: String(e.message || e) });
  }
});

app.delete('/api/avatar', (req, res) => {
  try { fs.unlinkSync(AVATAR_PATH); } catch (_) {}
  res.json({ ok: true });
});

// ---------- TTS (ElevenLabs, Steve's voice) ----------
app.post('/api/tts', async (req, res) => {
  const { text } = req.body || {};
  if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' });
  if (!ELEVEN_KEY) return res.status(503).json({ error: 'ELEVENLABS_API_KEY not configured' });
  try {
    const url = `https://api.elevenlabs.io/v1/text-to-speech/${ELEVEN_VOICE}/stream?optimize_streaming_latency=2`;
    const r = await fetch(url, {
      method: 'POST',
      headers: {
        'xi-api-key': ELEVEN_KEY,
        'accept': 'audio/mpeg',
        'content-type': 'application/json'
      },
      body: JSON.stringify({
        text,
        model_id: 'eleven_turbo_v2_5',
        voice_settings: { stability: 0.4, similarity_boost: 0.85, style: 0.2, use_speaker_boost: true }
      })
    });
    if (!r.ok) {
      const errText = await r.text();
      return res.status(502).json({ error: `eleven ${r.status}: ${errText.slice(0, 300)}` });
    }
    res.setHeader('content-type', 'audio/mpeg');
    res.setHeader('cache-control', 'no-store');
    const reader = r.body.getReader();
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      res.write(Buffer.from(value));
    }
    res.end();
  } catch (e) {
    res.status(500).json({ error: String(e.message || e) });
  }
});

app.get('/api/health', (req, res) => {
  res.json({
    ok: true,
    port: PORT,
    lan: `http://${LAN_HOST}:${PORT}`,
    voice: ELEVEN_VOICE,
    elevenConfigured: Boolean(ELEVEN_KEY),
    pairings: pairings.size
  });
});

// ---------- HTTP + WS ----------
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });

wss.on('connection', (ws, req) => {
  ws.role = null;
  ws.code = null;

  ws.on('message', (raw) => {
    let msg;
    try { msg = JSON.parse(raw.toString()); } catch { return; }

    if (msg.type === 'hello') {
      const code = String(msg.code || '');
      const role = msg.role === 'phone' ? 'phone' : 'desktop';
      if (!pairings.has(code)) {
        // Allow desktop to register a fresh code; for phone require code to exist
        if (role === 'phone') {
          ws.send(JSON.stringify({ type: 'error', error: 'unknown pair code' }));
          return ws.close();
        }
        pairings.set(code, { desktopWs: null, phoneWs: null, createdAt: Date.now() });
      }
      const p = pairings.get(code);
      ws.role = role;
      ws.code = code;
      if (role === 'desktop') p.desktopWs = ws;
      else p.phoneWs = ws;

      ws.send(JSON.stringify({ type: 'hello-ok', role, code }));
      // Notify counterpart
      const other = role === 'desktop' ? p.phoneWs : p.desktopWs;
      if (other && other.readyState === 1) {
        other.send(JSON.stringify({ type: 'peer-joined', role }));
        ws.send(JSON.stringify({ type: 'peer-ready' }));
      }
      return;
    }

    // Relay WebRTC signaling between paired peers
    if (!ws.code || !pairings.has(ws.code)) return;
    const p = pairings.get(ws.code);
    const target = ws.role === 'desktop' ? p.phoneWs : p.desktopWs;
    if (!target || target.readyState !== 1) return;

    if (['offer', 'answer', 'ice', 'phone-status', 'desktop-status'].includes(msg.type)) {
      target.send(JSON.stringify(msg));
    }
  });

  ws.on('close', () => {
    if (!ws.code || !pairings.has(ws.code)) return;
    const p = pairings.get(ws.code);
    if (ws.role === 'desktop' && p.desktopWs === ws) p.desktopWs = null;
    if (ws.role === 'phone' && p.phoneWs === ws) p.phoneWs = null;
    const other = ws.role === 'desktop' ? p.phoneWs : p.desktopWs;
    if (other && other.readyState === 1) {
      other.send(JSON.stringify({ type: 'peer-gone', role: ws.role }));
    }
    if (!p.desktopWs && !p.phoneWs) {
      pairings.delete(ws.code);
    }
  });
});

// Reap stale pairings (>30 min, no peers)
setInterval(() => {
  const now = Date.now();
  for (const [code, p] of pairings) {
    if (!p.desktopWs && !p.phoneWs && now - p.createdAt > 30 * 60_000) {
      pairings.delete(code);
    }
  }
}, 60_000).unref();

server.listen(PORT, '0.0.0.0', () => {
  console.log(`Big Red listening on :${PORT}`);
  console.log(`  desktop: http://localhost:${PORT}/`);
  console.log(`  phone:   http://${LAN_HOST}:${PORT}/phone (use QR from desktop)`);

  // Fire-and-forget warm-up so the first user request doesn't pay full cold-start.
  runClaude('Reply with: ok', { timeoutMs: 240_000 })
    .then(() => console.log('claude CLI warm'))
    .catch((e) => console.log('warm-up skipped:', e.message));
});