← back to Letsbegin

nina_full_monty.js

755 lines

#!/usr/bin/env node
/**
 * Nina Campbell Full Monty — ALL ~759 products
 * NO image cropping. NO room settings. NO SKU changes.
 *
 * Pipeline per product:
 *   1. MFR SKU — verify/extract from image filename if missing
 *   2. Pattern + Color — match catalog OR Gemini color analysis
 *   3. Title — "{Pattern} - {Color} Wallcovering | Nina Campbell"
 *   4. Body HTML — exactly 2 paragraphs (editorial + AI description)
 *   5. Metafields — brand, width, material, unit_of_measure, packaging, full_monty_date, pattern_name, color_name
 *   6. Migrate global.* → custom.* (width, repeat, fire_rating, Collection, Brand, etc.)
 *   7. Image alt tags — "{DW SKU} {Pattern} {Color} designer-wallcoverings-los-angeles"
 *   8. Tags — remove "Color:" prefix, add "Class A Fire Rated"
 *   9. NO image cropping. NO room settings. NO SKU changes.
 */

const https = require('https');
const http = require('http');
const fs = require('fs');
const { Client } = require('pg');

const STORE = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
const GEMINI_KEY = process.env.GEMINI_API_KEY;
const API_VER = '2024-10';
const FULL_MONTY_DATE = '2026-04-07';
const LOG_FILE = '/tmp/nina_monty.log';
const PG_URL = process.env.DATABASE_URL;
const PAGE_SIZE = 25;
const API_DELAY = 500;  // ms between Shopify API calls
const GEMINI_DELAY = 1200; // ms between Gemini calls

if (!TOKEN || !GEMINI_KEY || !PG_URL) {
  throw new Error('SHOPIFY_PRODUCT_TOKEN, GEMINI_API_KEY, and DATABASE_URL environment variables are required');
}

// Initialize log
fs.writeFileSync(LOG_FILE, `=== Nina Campbell Full Monty ===\nStarted: ${new Date().toISOString()}\n\n`);

function log(msg) {
  const line = `[${new Date().toISOString()}] ${msg}`;
  console.log(line);
  fs.appendFileSync(LOG_FILE, line + '\n');
}

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

// ---- GraphQL helpers ----

function gqlRaw(body) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = https.request({
      hostname: STORE,
      path: `/admin/api/${API_VER}/graphql.json`,
      method: 'POST',
      headers: {
        'X-Shopify-Access-Token': TOKEN,
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(data),
      },
    }, res => {
      let c = '';
      res.on('data', d => c += d);
      res.on('end', () => {
        try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); }
      });
    });
    req.on('error', reject);
    req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
    req.write(data);
    req.end();
  });
}

async function gql(body, retries = 3) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const result = await gqlRaw(body);
      // Check for throttling
      if (result?.errors?.[0]?.message?.includes('Throttled')) {
        log(`  GQL throttled, waiting 3s (attempt ${attempt}/${retries})`);
        await sleep(3000);
        continue;
      }
      return result;
    } catch (e) {
      if (attempt < retries) {
        log(`  GQL error: ${e.message}, retrying in ${attempt * 2}s`);
        await sleep(attempt * 2000);
        continue;
      }
      throw e;
    }
  }
}

// ---- Image download ----

function downloadImage(url, dest) {
  return new Promise((resolve, reject) => {
    const mod = url.startsWith('https') ? https : http;
    const file = fs.createWriteStream(dest);
    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        file.close();
        return downloadImage(res.headers.location, dest).then(resolve).catch(reject);
      }
      if (res.statusCode !== 200) {
        file.close();
        return reject(new Error(`HTTP ${res.statusCode}`));
      }
      res.pipe(file);
      file.on('finish', () => { file.close(); resolve(); });
    }).on('error', e => { file.close(); reject(e); });
  });
}

// ---- Gemini API ----

function geminiCall(reqBody) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(reqBody);
    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`);
    const req = https.request({
      hostname: url.hostname,
      path: url.pathname + url.search,
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
    }, res => {
      const chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => {
        try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch { resolve(null); }
      });
    });
    req.on('error', () => resolve(null));
    req.setTimeout(120000, () => { req.destroy(); resolve(null); });
    req.write(data);
    req.end();
  });
}

// ---- Title/text helpers ----

function toTitleCase(s) {
  if (!s) return '';
  return s.replace(/\b\w+/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
}

// Clean pattern name: remove "(Wa3)" suffix, clean up collection prefix junk
function cleanPatternName(raw) {
  if (!raw) return '';
  let name = raw
    .replace(/\s*\(Wa\d+\)\s*/gi, '')          // Remove (Wa3) etc
    .replace(/\s*Wallaper\b/gi, '')             // Typo in catalog "Wallaper"
    .replace(/\s*Wallpaper\b/gi, '')
    .replace(/\s*Wallcovering\b/gi, '')
    .trim();

  // If pattern has collection prefix like "Les Reves Collioure Blue/Beige"
  // and the color portion is after the last space-slash, we want to keep the whole pattern
  // but strip color-like suffixes that belong in color_name
  // e.g. "Fontibre Kershaw Plain Black/Ivory" -> pattern = "Fontibre Kershaw Plain"
  // But we keep it all — the color_name field handles the color separately
  // Just strip trailing color descriptions after the last known pattern word
  // Actually: keep the full pattern_name as-is from catalog. Color comes from color_name field.

  return name;
}

// Extract NCW/NCF code from image filename or URL
function extractMfrSkuFromUrl(url) {
  if (!url) return null;
  // Match NCW####-## or NCF####-## pattern
  const match = url.match(/(NC[WF]\d{3,5}-\d{1,2})/i);
  return match ? match[1].toUpperCase() : null;
}

// ---- Global → Custom field mapping ----

function cleanWidth(v) {
  if (!v) return null;
  const m = v.match(/([\d.]+)/);
  return m ? `${m[1]} Inches` : null;
}

function cleanRepeat(v) {
  if (!v || v === 'N/A' || v === '0' || v.toLowerCase() === 'random' || v.toLowerCase() === 'none') return null;
  const m = v.match(/([\d.]+)/);
  return m ? `${m[1]} Inches` : null;
}

function cleanFireRating(v) {
  if (!v) return null;
  let f = v.replace(/["']/g, '').trim();
  if (f.length > 100) return null;
  if (/class\s*a/i.test(f) && !f.includes('ASTM')) f = 'ASTM E-84 Class A';
  return f || null;
}

function cleanMaterial(v) {
  if (!v) return null;
  return v.replace(/\bWallpaper\b/gi, 'Wallcovering').replace(/\bWallpapers\b/gi, 'Wallcoverings').trim() || null;
}

const FIELD_MAP = {
  'width':              { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
  'Width':              { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
  'repeat':             { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'Vert-Rpt':           { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'Vert-Repeat':        { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'Horiz-Rpt':          { key: 'horizontal_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'Horiz-Repeat':       { key: 'horizontal_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
  'fire_rating':        { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
  'FLAMMABILITY':       { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
  'Contents':           { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
  'Content':            { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
  'Construction':       { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
  'Collection':         { key: 'collection_name', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Brand':              { key: 'brand', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'MATCH':              { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Match':              { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Finish':             { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'FINISH':             { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Cleaning':           { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Clean-Code':         { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'application':        { key: 'application', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Substrate':          { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'substrate':          { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'length':             { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Length':             { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'packaged':           { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Packaged':           { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'unit_of_measure':    { key: 'unit_of_measure', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Weight':             { key: 'product_weight', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Country':            { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Country-of-Origin':  { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'color':              { key: 'color', clean: v => v?.trim() || null, type: 'single_line_text_field' },
  'Color-Way':          { key: 'color', clean: v => v?.trim() || null, type: 'single_line_text_field' },
};

// ---- Load catalog from PostgreSQL ----

async function loadCatalog() {
  const client = new Client({ connectionString: PG_URL });
  await client.connect();
  const res = await client.query('SELECT mfr_sku, pattern_name, color_name, collection FROM nina_campbell_catalog');
  await client.end();

  const byMfr = {};
  for (const row of res.rows) {
    if (row.mfr_sku) {
      byMfr[row.mfr_sku.toUpperCase()] = row;
    }
  }
  log(`Loaded ${res.rows.length} catalog entries from nina_campbell_catalog`);
  return byMfr;
}

// ---- Fetch all products ----

async function fetchAllProducts() {
  log('Fetching all Nina Campbell active products...');
  const allProducts = [];
  let cursor = null;

  while (true) {
    const after = cursor ? `, after: "${cursor}"` : '';
    const query = `{
      products(first: ${PAGE_SIZE}, query: "vendor:\\"Nina Campbell\\" AND status:active"${after}) {
        edges {
          cursor
          node {
            id
            title
            descriptionHtml
            vendor
            tags
            images(first: 10) {
              edges { node { id url altText } }
            }
            media(first: 10) {
              edges { node { ... on MediaImage { id image { url altText } } } }
            }
            variants(first: 5) {
              edges { node { id sku title } }
            }
            metafields(first: 50) {
              edges { node { namespace key value type } }
            }
          }
        }
        pageInfo { hasNextPage }
      }
    }`;

    const r = await gql({ query });

    if (r?.errors?.length) {
      log(`  GQL top-level error: ${JSON.stringify(r.errors[0]).slice(0, 200)}`);
    }

    const edges = r?.data?.products?.edges || [];
    if (edges.length === 0) break;

    for (const edge of edges) {
      allProducts.push(edge.node);
      cursor = edge.cursor;
    }

    log(`  Fetched ${allProducts.length} products so far...`);
    if (!r?.data?.products?.pageInfo?.hasNextPage) break;
    await sleep(API_DELAY);
  }

  log(`Total products fetched: ${allProducts.length}\n`);
  return allProducts;
}

// ---- Process a single product ----

async function processProduct(p, idx, total, catalog) {
  const pid = p.id;
  const origTitle = p.title;
  log(`\n[${idx + 1}/${total}] ${origTitle}`);

  // Collect metafields by namespace
  const globals = {};
  const customs = {};
  for (const m of (p.metafields?.edges || [])) {
    const { namespace, key, value } = m.node;
    if (namespace === 'global' && value) globals[key] = value;
    if (namespace === 'custom' && value) customs[key] = value;
  }

  // Get primary DW SKU from first non-sample variant
  const variants = p.variants?.edges || [];
  let dwSku = '';
  for (const v of variants) {
    const s = v.node.sku || '';
    if (!s.toLowerCase().endsWith('-sample') && s) { dwSku = s; break; }
  }
  if (!dwSku && variants.length > 0) dwSku = variants[0].node.sku || '';

  const result = { specs: 0, color: false, body: false, title: false, montyDate: false, alt: 0, tags: false, errors: [] };

  // ============================================================
  // STEP 1: MFR SKU — verify or extract from image filename
  // ============================================================
  let mfrSku = (customs['manufacturer_sku'] || '').toUpperCase().trim();

  if (!mfrSku) {
    // Try to extract NCW/NCF code from image URLs
    for (const img of (p.images?.edges || [])) {
      const extracted = extractMfrSkuFromUrl(img.node.url);
      if (extracted) {
        mfrSku = extracted;
        log(`  Step 1 MFR SKU: extracted from image filename: ${mfrSku}`);
        break;
      }
    }
    // Also try title
    if (!mfrSku) {
      const titleMatch = origTitle.match(/(NC[WF]\d{3,5}-\d{1,2})/i);
      if (titleMatch) mfrSku = titleMatch[1].toUpperCase();
    }
  }

  // ============================================================
  // STEP 2: Pattern + Color — catalog match or Gemini
  // ============================================================
  let patternName = customs['pattern_name'] || '';
  let colorName = customs['color_name'] || '';
  let catalogMatch = false;

  // Check catalog
  const catEntry = mfrSku ? catalog[mfrSku] : null;
  if (catEntry) {
    const rawPattern = cleanPatternName(catEntry.pattern_name);
    // Only update if current pattern is bad (like "Nina Campbell s") or empty
    if (!patternName || patternName === 'Nina Campbell s' || patternName.length < 3) {
      patternName = rawPattern;
    }
    if (!colorName || colorName.length < 2) {
      colorName = toTitleCase(catEntry.color_name);
    }
    catalogMatch = true;
    log(`  Step 2 Catalog match: ${mfrSku} -> "${patternName}" / "${colorName}"`);
  }

  // If no color, use Gemini on the image
  const imgUrl = p.images?.edges?.[0]?.node?.url;
  let aiDesc = '';
  let needGeminiColor = !colorName && imgUrl;
  let needGeminiDesc = true; // Always get AI description for body

  if (needGeminiColor || needGeminiDesc) {
    if (imgUrl) {
      try {
        const tmpImg = '/tmp/nina_monty_img.jpg';
        await downloadImage(imgUrl, tmpImg);
        const imgB64 = fs.readFileSync(tmpImg).toString('base64');

        // Combined call: get color (if needed) + description
        if (needGeminiColor) {
          const colorResult = await geminiCall({
            contents: [{ parts: [
              { text: 'What is the dominant color of this wallcovering? Use designer terms. Reply 1-2 words only.' },
              { inlineData: { mimeType: 'image/jpeg', data: imgB64 } },
            ]}],
            generationConfig: { temperature: 0.2, maxOutputTokens: 50 },
          });
          const colorText = (colorResult?.candidates?.[0]?.content?.parts?.[0]?.text || '').trim();
          if (colorText && colorText.length < 30) {
            colorName = toTitleCase(colorText.replace(/[.\n]/g, '').trim());
            log(`  Step 2 Gemini color: "${colorName}"`);
          }
          await sleep(GEMINI_DELAY);
        }

        // Get AI description (1 sentence about texture/pattern)
        const displayName = patternName || origTitle.split('|')[0].replace(/wallcovering/gi, '').trim();
        const descResult = await geminiCall({
          contents: [{ parts: [
            { text: `Describe this wallcovering in exactly 1 sentence for a design professional. Focus on texture, pattern, and visual effect. Do NOT use the word "wallpaper" — always say "wallcovering". Do NOT start with the product name. Do NOT mention the brand. Maximum 30 words.` },
            { inlineData: { mimeType: 'image/jpeg', data: imgB64 } },
          ]}],
          generationConfig: { temperature: 0.3, maxOutputTokens: 100 },
        });
        aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '')
          .replace(/wallpaper/gi, 'wallcovering')
          .replace(/\n/g, ' ')
          .trim();
        await sleep(GEMINI_DELAY);

        // Clean up temp file
        try { fs.unlinkSync(tmpImg); } catch {}
      } catch (e) {
        result.errors.push(`Gemini: ${(e.message || '').slice(0, 60)}`);
        log(`  Step 2 Gemini ERROR: ${(e.message || '').slice(0, 60)}`);
      }
    }
  }

  // If pattern still bad, try parsing from title
  if (!patternName || patternName === 'Nina Campbell s' || patternName.length < 3) {
    // Extract from title: "Something - Color Wallcovering | Nina Campbell"
    let cleaned = origTitle.replace(/\s*\|\s*Nina Campbell\s*(Europe)?\s*$/i, '').trim();
    cleaned = cleaned.replace(/\s*Wallcovering\s*$/i, '').trim();
    const dashIdx = cleaned.lastIndexOf(' - ');
    if (dashIdx > 0) {
      patternName = cleaned.substring(0, dashIdx).trim();
    } else {
      patternName = cleaned;
    }
  }

  // Fallback color
  if (!colorName) {
    colorName = 'Natural';
  }

  // ============================================================
  // STEP 3: Title — "{Pattern} - {Color} Wallcovering | Nina Campbell"
  // ============================================================
  const newTitle = `${patternName} - ${colorName} Wallcovering | Nina Campbell`;
  if (newTitle !== origTitle) {
    try {
      const r = await gql({
        query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
        variables: { i: { id: pid, title: newTitle } },
      });
      const errs = r?.data?.productUpdate?.userErrors || [];
      if (errs.length > 0) {
        result.errors.push(`Title: ${errs[0].message}`);
      } else {
        result.title = true;
        log(`  Step 3 Title: "${origTitle}" -> "${newTitle}"`);
      }
    } catch (e) {
      result.errors.push(`Title: ${(e.message || '').slice(0, 40)}`);
    }
    await sleep(API_DELAY);
  } else {
    result.title = true;
    log(`  Step 3 Title: already correct`);
  }

  // ============================================================
  // STEP 4: Body HTML — exactly 2 paragraphs
  // ============================================================
  const p1 = `${patternName} in ${colorName} is a refined wallcovering by Nina Campbell. This design brings elegant sophistication to residential and commercial interiors.`;
  const p2 = aiDesc || 'Featuring a carefully curated palette and understated texture, this wallcovering delivers timeless appeal suitable for discerning design applications.';
  const newBody = `<p>${p1}</p>\n<p>${p2}</p>`;

  try {
    const r = await gql({
      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
      variables: { i: { id: pid, descriptionHtml: newBody } },
    });
    const errs = r?.data?.productUpdate?.userErrors || [];
    if (errs.length > 0) {
      result.errors.push(`Body: ${errs[0].message}`);
    } else {
      result.body = true;
      log(`  Step 4 Body: 2 paragraphs set`);
    }
  } catch (e) {
    result.errors.push(`Body: ${(e.message || '').slice(0, 40)}`);
  }
  await sleep(API_DELAY);

  // ============================================================
  // STEP 5: Metafields — required fields + migrate global.* -> custom.*
  // ============================================================
  const mfToSet = [];
  const seen = new Set();

  // Required metafields (always set/overwrite)
  const requiredMf = [
    { key: 'pattern_name', value: patternName, type: 'single_line_text_field' },
    { key: 'color_name', value: colorName, type: 'single_line_text_field' },
    { key: 'brand', value: 'Nina Campbell', type: 'single_line_text_field' },
    { key: 'width', value: '20.5 Inches', type: 'single_line_text_field' },
    { key: 'material', value: 'Wallcovering', type: 'multi_line_text_field' },
    { key: 'unit_of_measure', value: 'Priced Per Single Roll', type: 'single_line_text_field' },
    { key: 'packaging', value: 'Double Roll', type: 'single_line_text_field' },
    { key: 'full_monty_date', value: FULL_MONTY_DATE, type: 'single_line_text_field' },
  ];

  // Set MFR SKU if we found one and it's not already set
  if (mfrSku && !customs['manufacturer_sku']) {
    requiredMf.push({ key: 'manufacturer_sku', value: mfrSku, type: 'single_line_text_field' });
  }

  for (const mf of requiredMf) {
    mfToSet.push({
      ownerId: pid, namespace: 'custom', key: mf.key,
      value: mf.value, type: mf.type,
    });
    seen.add(mf.key);
  }

  // Migrate global.* -> custom.* for unmapped fields
  for (const [gKey, mapping] of Object.entries(FIELD_MAP)) {
    if (!globals[gKey]) continue;
    if (seen.has(mapping.key)) continue; // Already set by required fields
    if (customs[mapping.key]) continue;  // Already has custom value

    const cleaned = mapping.clean(globals[gKey]);
    if (!cleaned) continue;

    mfToSet.push({
      ownerId: pid, namespace: 'custom', key: mapping.key,
      value: cleaned, type: mapping.type,
    });
    seen.add(mapping.key);
  }

  if (mfToSet.length > 0) {
    try {
      const r = await gql({
        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
        variables: { m: mfToSet },
      });
      const errs = r?.data?.metafieldsSet?.userErrors || [];
      if (errs.length > 0) {
        result.errors.push(`Metafields: ${errs.map(e => e.message).join('; ').slice(0, 100)}`);
        log(`  Step 5 Metafields: ERRORS ${errs[0].message}`);
      } else {
        result.specs = mfToSet.length;
        log(`  Step 5 Metafields: ${mfToSet.length} fields set`);
      }
    } catch (e) {
      result.errors.push(`Metafields: ${(e.message || '').slice(0, 40)}`);
    }
    await sleep(API_DELAY);
  }

  // ============================================================
  // STEP 7: Image alt tags — "{DW SKU} {Pattern} {Color} designer-wallcoverings-los-angeles"
  // ============================================================
  const cleanSku = dwSku.replace(/-[Ss]ample$/, '').trim();
  const altBase = `${cleanSku} ${patternName} ${colorName} designer-wallcoverings-los-angeles`.replace(/\s+/g, ' ').trim();
  const mediaEdges = p.media?.edges || [];

  if (mediaEdges.length > 0) {
    const mediaUpdates = [];
    for (let mi = 0; mi < mediaEdges.length; mi++) {
      const mediaNode = mediaEdges[mi].node;
      if (!mediaNode?.id) continue;
      const altTag = mi === 0 ? altBase : `${altBase} ${mi + 1}`;
      mediaUpdates.push({ id: mediaNode.id, alt: altTag });
    }

    if (mediaUpdates.length > 0) {
      try {
        const mediaJson = mediaUpdates
          .map(m => `{id: "${m.id}", alt: "${m.alt.replace(/"/g, '\\"')}"}`)
          .join(', ');
        const r = await gql({
          query: `mutation { productUpdateMedia(productId: "${pid}", media: [${mediaJson}]) { media { ... on MediaImage { id } } mediaUserErrors { message } } }`,
        });
        const errs = r?.data?.productUpdateMedia?.mediaUserErrors || [];
        if (errs.length > 0) {
          result.errors.push(`Alt: ${errs[0].message}`);
        } else {
          result.alt = mediaUpdates.length;
          log(`  Step 7 Alt tags: ${mediaUpdates.length} images updated`);
        }
      } catch (e) {
        result.errors.push(`Alt: ${(e.message || '').slice(0, 40)}`);
      }
      await sleep(API_DELAY);
    }
  } else {
    log(`  Step 7 Alt tags: no media found`);
  }

  // ============================================================
  // STEP 8: Tags — remove "Color:" prefix, add "Class A Fire Rated", add MFR SKU as tag
  // ============================================================
  const currentTags = p.tags || [];
  let newTags = [...currentTags];
  let tagsChanged = false;

  // Remove "Color:" prefix from tags
  for (let ti = 0; ti < newTags.length; ti++) {
    if (newTags[ti].startsWith('Color:')) {
      newTags[ti] = newTags[ti].replace(/^Color:\s*/, '').trim();
      tagsChanged = true;
    }
  }

  // Add "Class A Fire Rated" if not present
  if (!newTags.some(t => t.toLowerCase() === 'class a fire rated')) {
    newTags.push('Class A Fire Rated');
    tagsChanged = true;
  }

  // Add MFR SKU as tag if not present
  if (mfrSku && !newTags.some(t => t.toUpperCase() === mfrSku)) {
    newTags.push(mfrSku);
    tagsChanged = true;
  }

  // Remove duplicates (case-insensitive)
  const tagSet = new Map();
  for (const t of newTags) {
    const lower = t.toLowerCase().trim();
    if (lower && !tagSet.has(lower)) tagSet.set(lower, t.trim());
  }
  newTags = Array.from(tagSet.values());

  if (tagsChanged) {
    try {
      const tagStr = newTags.join(', ');
      const r = await gql({
        query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
        variables: { i: { id: pid, tags: newTags } },
      });
      const errs = r?.data?.productUpdate?.userErrors || [];
      if (errs.length > 0) {
        result.errors.push(`Tags: ${errs[0].message}`);
      } else {
        result.tags = true;
        log(`  Step 8 Tags: updated (${newTags.length} tags)`);
      }
    } catch (e) {
      result.errors.push(`Tags: ${(e.message || '').slice(0, 40)}`);
    }
    await sleep(API_DELAY);
  } else {
    result.tags = true;
    log(`  Step 8 Tags: already correct`);
  }

  if (result.errors.length > 0) {
    log(`  ERRORS: ${result.errors.join(' | ')}`);
  }

  return result;
}

// ---- Main ----

async function main() {
  log('=== Nina Campbell Full Monty (NO Room Settings, NO Image Cropping, NO SKU Changes) ===');
  log(`Date: ${FULL_MONTY_DATE}`);
  log(`Store: ${STORE}`);
  log(`Page size: ${PAGE_SIZE}`);
  log(`API delay: ${API_DELAY}ms | Gemini delay: ${GEMINI_DELAY}ms\n`);

  // Load catalog from PostgreSQL
  const catalog = await loadCatalog();

  // Fetch all products
  const products = await fetchAllProducts();

  const stats = {
    total: products.length,
    catalogMatches: 0,
    geminiColors: 0,
    specsFieldsSet: 0,
    bodiesRewritten: 0,
    titlesUpdated: 0,
    montyDatesSet: 0,
    altTagsUpdated: 0,
    tagsUpdated: 0,
    errors: 0,
  };

  for (let i = 0; i < products.length; i++) {
    try {
      const result = await processProduct(products[i], i, products.length, catalog);
      stats.specsFieldsSet += result.specs;
      if (result.color) stats.geminiColors++;
      if (result.body) stats.bodiesRewritten++;
      if (result.title) stats.titlesUpdated++;
      stats.altTagsUpdated += result.alt;
      if (result.tags) stats.tagsUpdated++;
      stats.errors += result.errors.length;
    } catch (e) {
      log(`  FATAL ERROR on product ${i + 1}: ${e.message}`);
      stats.errors++;
    }

    // Progress every 25
    if ((i + 1) % 25 === 0 || i === products.length - 1) {
      log(`\n--- PROGRESS: ${i + 1}/${products.length} (${((i + 1) / products.length * 100).toFixed(1)}%) ---`);
      log(`  Specs: ${stats.specsFieldsSet} | Bodies: ${stats.bodiesRewritten} | Titles: ${stats.titlesUpdated} | Alt: ${stats.altTagsUpdated} | Tags: ${stats.tagsUpdated} | Errors: ${stats.errors}\n`);
    }
  }

  const summary = `
=== NINA CAMPBELL FULL MONTY COMPLETE ===
Total products: ${stats.total}
Specs/metafields set: ${stats.specsFieldsSet}
Bodies rewritten: ${stats.bodiesRewritten}
Titles updated: ${stats.titlesUpdated}
Alt tags updated: ${stats.altTagsUpdated}
Tags updated: ${stats.tagsUpdated}
Errors: ${stats.errors}
Finished: ${new Date().toISOString()}
`;
  log(summary);
}

main().catch(e => {
  log(`FATAL: ${e.message}\n${e.stack}`);
  process.exit(1);
});