123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
/**
 * JLCPCB Component Search Server — Shared Service Edition
 *
 * HTTP API backed by a local SQLite database (from CDFER/jlcpcb-parts-database).
 * No upstream dependency at query time — fully self-contained.
 *
 * This runs as a shared service so individual user containers don't need
 * the 1.3GB SQLite database.
 *
 * Port: 8774 (configurable via JLCPCB_PORT)
 * DB:   /data/jlcpcb-components.sqlite3 (configurable via JLCPCB_DB_PATH)
 *
 * Supports SIGHUP for graceful DB reload (used by the daily cron job).
 */

import { createServer } from 'http';
import { existsSync, statSync } from 'fs';
// Also expose as `fs.statSync` so the telemetry block reads naturally.
const fs = { statSync };
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import Database from 'better-sqlite3';

const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.JLCPCB_PORT || '8774', 10);
const DB_PATH = process.env.JLCPCB_DB_PATH || join(__dirname, 'jlcpcb-components.sqlite3');

// ── Database management ─────────────────────────────────────────
let db, ftsTable, allCategories, CATEGORY_MAP;

function openDatabase() {
  if (!existsSync(DB_PATH)) {
    console.error(`[jlcpcb] Database not found at ${DB_PATH}`);
    console.error(`[jlcpcb] Run: node setup-db.js`);
    process.exit(1);
  }

  db = new Database(DB_PATH, { readonly: true });
  db.pragma('journal_mode = WAL');
  db.pragma('cache_size = -64000');

  const hasSearchFTS = Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='search_fts'").get());
  const hasComponentsFTS = Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='components_fts'").get());
  ftsTable = hasSearchFTS ? 'search_fts' : hasComponentsFTS ? 'components_fts' : null;

  if (!ftsTable) {
    console.warn('[jlcpcb] WARNING: No FTS table found. Full-text search will use LIKE (slower).');
  }

  allCategories = db.prepare('SELECT id, category, subcategory FROM categories').all();
  CATEGORY_MAP = buildCategoryMap();

  console.log(`[jlcpcb] Database loaded: ${DB_PATH}`);
  console.log(`[jlcpcb] FTS: ${ftsTable || 'none (LIKE fallback)'}`);
}

function buildCategoryMap() {
  function findCategoryIds(opts) {
    return allCategories
      .filter(c => {
        if (opts.subcategoryLike) return c.subcategory.toLowerCase().includes(opts.subcategoryLike.toLowerCase());
        if (opts.categoryLike) return c.category.toLowerCase().includes(opts.categoryLike.toLowerCase());
        return false;
      })
      .map(c => c.id);
  }

  const map = {
    resistors:              findCategoryIds({ categoryLike: 'resistor' }),
    capacitors:             findCategoryIds({ categoryLike: 'capacitor' }),
    microcontrollers:       findCategoryIds({ subcategoryLike: 'microcontroller' }),
    ldos:                   findCategoryIds({ subcategoryLike: 'LDO' }),
    voltage_regulators:     findCategoryIds({ subcategoryLike: 'voltage regulator' }),
    boost_converters:       findCategoryIds({ subcategoryLike: 'DC-DC' }),
    buck_boost_converters:  findCategoryIds({ subcategoryLike: 'DC-DC' }),
    mosfets:                findCategoryIds({ subcategoryLike: 'MOSFET' }),
    bjt_transistors:        findCategoryIds({ subcategoryLike: 'bipolar transistor' }),
    diodes:                 findCategoryIds({ categoryLike: 'diode' }),
    leds:                   findCategoryIds({ subcategoryLike: 'LED' }),
    led_drivers:            findCategoryIds({ subcategoryLike: 'LED driver' }),
    switches:               findCategoryIds({ subcategoryLike: 'switch' }),
    fuses:                  findCategoryIds({ subcategoryLike: 'fuse' }),
    relays:                 findCategoryIds({ subcategoryLike: 'relay' }),
    headers:                findCategoryIds({ subcategoryLike: 'pin header' }),
    usb_c_connectors:       findCategoryIds({ subcategoryLike: 'USB connector' }),
    jst_connectors:         findCategoryIds({ subcategoryLike: 'wire to board' }),
    wire_to_board_connectors: findCategoryIds({ subcategoryLike: 'wire to board' }),
    io_expanders:           findCategoryIds({ subcategoryLike: 'I/O expander' }),
    fpgas:                  findCategoryIds({ subcategoryLike: 'FPGA' }),
    arm_processors:         findCategoryIds({ subcategoryLike: 'microcontroller' }),
    risc_v_processors:      findCategoryIds({ subcategoryLike: 'microcontroller' }),
    wifi_modules:           findCategoryIds({ subcategoryLike: 'WiFi' }),
    accelerometers:         findCategoryIds({ subcategoryLike: 'accelerometer' }),
    gyroscopes:             findCategoryIds({ subcategoryLike: 'gyroscope' }),
    gas_sensors:            findCategoryIds({ subcategoryLike: 'gas sensor' }),
    adcs:                   findCategoryIds({ subcategoryLike: 'analog to digital' }),
    dacs:                   findCategoryIds({ subcategoryLike: 'digital to analog' }),
    analog_multiplexers:    findCategoryIds({ subcategoryLike: 'analog switch' }),
    analog_switches:        findCategoryIds({ subcategoryLike: 'analog switch' }),
    potentiometers:         findCategoryIds({ subcategoryLike: 'potentiometer' }),
    resistor_arrays:        findCategoryIds({ subcategoryLike: 'resistor network' }),
    battery_holders:        findCategoryIds({ subcategoryLike: 'battery' }),
    lcd_display:            findCategoryIds({ subcategoryLike: 'LCD' }),
    oled_display:           findCategoryIds({ subcategoryLike: 'OLED' }),
  };

  for (const [name, ids] of Object.entries(map)) {
    if (ids.length === 0) console.warn(`[jlcpcb] CATEGORY_MAP: no match for "${name}"`);
  }

  return map;
}

// Open DB on startup
openDatabase();

// SIGHUP handler: gracefully reload the database (used by cron after DB swap)
process.on('SIGHUP', () => {
  console.log('[jlcpcb] SIGHUP received — reloading database...');
  try {
    db.close();
    openDatabase();
    console.log('[jlcpcb] Database reloaded successfully.');
  } catch (err) {
    console.error('[jlcpcb] Failed to reload database:', err.message);
  }
});

// ── Price extraction ────────────────────────────────────────────
function extractPriceTiers(priceField) {
  if (!priceField) return [];
  try {
    const parsed = typeof priceField === 'string' ? JSON.parse(priceField) : priceField;
    if (!Array.isArray(parsed)) return [];
    return parsed.map(t => ({
      qty_from: t.qFrom ?? t.qty_from ?? null,
      qty_to: t.qTo ?? t.qty_to ?? null,
      price_usd: typeof t.price === 'number' ? Math.round(t.price * 10000) / 10000 : null,
    }));
  } catch {
    return [];
  }
}

function unitPrice(priceField) {
  const tiers = extractPriceTiers(priceField);
  if (tiers.length === 0) {
    const n = Number(priceField);
    return Number.isFinite(n) ? Math.round(n * 10000) / 10000 : null;
  }
  return tiers[0]?.price_usd ?? null;
}

// ── Extra field parsing ─────────────────────────────────────────
function parseExtra(extra) {
  if (!extra) return {};
  try {
    return typeof extra === 'string' ? JSON.parse(extra) : extra;
  } catch {
    return {};
  }
}

// ── Normalize a component row ───────────────────────────────────
function normalizeComponent(row) {
  const extra = parseExtra(row.extra);

  const result = {
    lcsc: `C${row.lcsc}`,
    lcsc_id: row.lcsc,
    mfr: row.mfr || null,
    description: extra?.description || row.description || null,
    package: row.package || null,
    stock: row.stock ?? 0,
    is_basic: Boolean(row.basic),
    is_preferred: Boolean(row.preferred),
    joints: row.joints ?? null,
    unit_price_usd: unitPrice(row.price),
    price_tiers: extractPriceTiers(row.price),
    datasheet: row.datasheet || extra?.datasheet?.url || null,
  };

  if (extra) {
    if (extra.manufacturer?.name) result.manufacturer = extra.manufacturer.name;
    if (extra.images) {
      const imgs = Array.isArray(extra.images) ? extra.images : [extra.images];
      const first = imgs[0];
      if (first) result.image_url = first['900x900'] || first['224x224'] || first['96x96'] || null;
      result.images = imgs.map(img => img['900x900'] || img['224x224'] || img['96x96']).filter(Boolean);
    }
    if (extra.attributes) result.attributes = extra.attributes;
    if (extra.url) result.jlcpcb_url = extra.url;
    result.jlcpcb_parts_url = `https://jlcpcb.com/parts/componentSearch?searchTxt=${result.lcsc}`;
  }

  return result;
}

// ── Popularity scoring ──────────────────────────────────────────
function popularityTier(stock) {
  if (stock >= 50000) return 'very_high';
  if (stock >= 10000) return 'high';
  if (stock >= 2000) return 'medium';
  if (stock >= 500) return 'low';
  return 'very_low';
}

function enrichWithPopularity(component) {
  return { ...component, popularity: popularityTier(component.stock) };
}

// ── Build dynamic WHERE for category_ids ────────────────────────
function categoryIdPlaceholders(ids) {
  return ids.map(() => '?').join(',');
}

// ── Action handlers ─────────────────────────────────────────────

function handleSearch({ query, keyword, limit = 20, package: pkg, is_basic, is_preferred }) {
  // Accept both `query` (historical) and `keyword` (matches the rest of
  // the adom ecosystem: adom-mouser, adom-digikey, adom-parts-search all
  // send `keyword`). Either works; `keyword` wins if both are set.
  query = keyword || query;
  const safeLimit = Math.min(Number(limit) || 20, 100);
  const overFetch = safeLimit * 3;
  let rows;

  if (!query || !String(query).trim()) {
    rows = db.prepare('SELECT * FROM components WHERE stock > 0 ORDER BY stock DESC LIMIT ?').all(safeLimit);
  } else if (/^c\d+$/i.test(query.trim())) {
    const lcscNum = parseInt(query.trim().replace(/^c/i, ''), 10);
    const row = db.prepare('SELECT * FROM components WHERE lcsc = ? AND stock > 0').get(lcscNum);
    rows = row ? [row] : [];
  } else if (ftsTable === 'search_fts') {
    // Use FTS5 for full-text search across mfr, package, description
    // Strip characters that break FTS5 MATCH syntax, quote each token
    const ftsQuery = query
      .replace(/['"(){}[\]:!@#$%^&*+=|\\/<>~`]/g, ' ')
      .trim()
      .split(/\s+/)
      .filter(Boolean)
      .map(t => `"${t}"`)
      .join(' ');
    try {
      rows = db.prepare(`
        SELECT c.* FROM components c
        JOIN search_fts fts ON c.rowid = fts.rowid
        WHERE search_fts MATCH ? AND c.stock > 0
        ORDER BY c.stock DESC LIMIT ?
      `).all(ftsQuery, overFetch);
    } catch {
      rows = []; // FTS parse error — fall through to LIKE
    }
    // If FTS returns nothing, fall back to LIKE on mfr
    if (rows.length === 0) {
      const pattern = `%${query}%`;
      rows = db.prepare(`
        SELECT * FROM components
        WHERE mfr LIKE ? AND stock > 0
        ORDER BY stock DESC LIMIT ?
      `).all(pattern, overFetch);
    }
  } else {
    // No FTS table — fall back to LIKE on mfr
    const pattern = `%${query}%`;
    rows = db.prepare(`
      SELECT * FROM components
      WHERE mfr LIKE ? AND stock > 0
      ORDER BY stock DESC LIMIT ?
    `).all(pattern, overFetch);
  }

  if (pkg) rows = rows.filter(r => r.package?.includes(pkg));
  if (is_basic === true || is_basic === 'true') rows = rows.filter(r => r.basic === 1);
  if (is_preferred === true || is_preferred === 'true') rows = rows.filter(r => r.preferred === 1);
  rows = rows.slice(0, safeLimit);

  const components = rows.map(r => enrichWithPopularity(normalizeComponent(r)));
  return { count: components.length, components };
}

function handleListComponents({ subcategory, search, keyword, package: pkg, limit = 20, is_basic, is_preferred }) {
  search = keyword || search;
  const safeLimit = Math.min(Number(limit) || 20, 100);
  let sql = 'SELECT c.* FROM components c';
  const params = [];

  if (subcategory) {
    sql += ' JOIN categories cat ON c.category_id = cat.id';
    sql += ' WHERE c.stock > 0 AND cat.subcategory = ?';
    params.push(subcategory);
  } else {
    sql += ' WHERE c.stock > 0';
  }

  if (search) {
    sql += ' AND (c.mfr LIKE ? OR c.description LIKE ? OR CAST(c.lcsc AS TEXT) LIKE ?)';
    const pattern = `%${search}%`;
    params.push(pattern, pattern, pattern);
  }

  if (pkg) { sql += ' AND c.package = ?'; params.push(pkg); }
  if (is_basic === true || is_basic === 'true') sql += ' AND c.basic = 1';
  if (is_preferred === true || is_preferred === 'true') sql += ' AND c.preferred = 1';

  sql += ' ORDER BY c.stock DESC LIMIT ?';
  params.push(safeLimit);

  const rows = db.prepare(sql).all(...params);
  const components = rows.map(r => enrichWithPopularity(normalizeComponent(r)));
  return { count: components.length, components };
}

function handleCategorySearch({ category, filters = {}, limit = 20 }) {
  if (typeof filters === 'string') {
    try { filters = JSON.parse(filters); } catch { filters = {}; }
  }

  const categoryIds = CATEGORY_MAP[category];
  if (!categoryIds || categoryIds.length === 0) {
    return handleListComponents({ subcategory: category, limit });
  }

  const safeLimit = Math.min(Number(limit) || 20, 100);
  let sql = `SELECT * FROM components WHERE stock > 0 AND category_id IN (${categoryIdPlaceholders(categoryIds)})`;
  const params = [...categoryIds];

  for (const [key, value] of Object.entries(filters)) {
    if (value === undefined || value === null || value === '') continue;
    if (key === 'package') {
      sql += ' AND package = ?';
      params.push(String(value));
    } else if (key === 'is_basic') {
      sql += ' AND basic = 1';
    } else if (key === 'is_preferred') {
      sql += ' AND preferred = 1';
    } else {
      sql += ' AND (mfr LIKE ? OR description LIKE ?)';
      const pattern = `%${value}%`;
      params.push(pattern, pattern);
    }
  }

  sql += ' ORDER BY stock DESC LIMIT ?';
  params.push(safeLimit);

  const rows = db.prepare(sql).all(...params);
  const components = rows.map(r => enrichWithPopularity(normalizeComponent(r)));
  return { count: components.length, category, components };
}

function handleCategories() {
  const rows = db.prepare(`
    SELECT cat.category, cat.subcategory, COUNT(*) as count
    FROM components c
    JOIN categories cat ON c.category_id = cat.id
    WHERE c.stock > 0
    GROUP BY cat.category, cat.subcategory
    ORDER BY cat.category, count DESC
  `).all();

  const hierarchy = {};
  for (const r of rows) {
    if (!hierarchy[r.category]) hierarchy[r.category] = [];
    hierarchy[r.category].push({ name: r.subcategory, count: r.count });
  }

  return { categories: hierarchy };
}

function handleHealth() {
  try {
    const total = db.prepare('SELECT COUNT(*) as n FROM components').get().n;
    const inStock = db.prepare('SELECT COUNT(*) as n FROM components WHERE stock > 0').get().n;

    // DB freshness telemetry. The file mtime tracks the last atomic
    // swap done by cron/update-db.sh, which pulls from
    // https://cdfer.github.io/jlcpcb-parts-database/
    // Expose age so operators don't need to SSH to answer "is the data
    // recent?". If db_age_hours climbs past ~36 h the nightly cron is
    // probably broken; past ~240 h CDFER's upstream may have stopped
    // publishing (they scrape LCSC; scrapers break).
    let dbLastModifiedIso = null;
    let dbAgeHours = null;
    let dbSizeBytes = null;
    try {
      const stat = fs.statSync(DB_PATH);
      dbLastModifiedIso = stat.mtime.toISOString();
      dbAgeHours = Math.round((Date.now() - stat.mtimeMs) / 36e5 * 10) / 10;
      dbSizeBytes = stat.size;
    } catch { /* DB path unreachable — ok/false already ran */ }

    return {
      ok: true,
      source: 'local_sqlite',
      upstream: 'https://cdfer.github.io/jlcpcb-parts-database/',
      total_components: total,
      in_stock: inStock,
      fts_table: ftsTable || 'none',
      db_path: DB_PATH,
      db_last_modified_iso: dbLastModifiedIso,
      db_age_hours: dbAgeHours,
      db_size_bytes: dbSizeBytes,
      refresh_cron: '0 4 * * *  (daily 04:00 UTC)',
    };
  } catch (err) {
    return { ok: false, source: 'local_sqlite', error: err.message };
  }
}

// ── Landing page ────────────────────────────────────────────────
function landingPage(health, baseUrl) {
  const n = (v) => (v ?? 0).toLocaleString();
  const api = baseUrl;
  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JLCPCB Component Search API</title>
<style>
  *{box-sizing:border-box;margin:0;padding:0}
  body{background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;padding:32px 24px}
  .container{max-width:860px;margin:0 auto}
  h1{font-size:24px;font-weight:700;margin-bottom:4px;display:flex;align-items:center;gap:10px}
  h1 .dot{width:10px;height:10px;border-radius:50%;background:${health.ok ? '#3fb950' : '#f85149'};flex-shrink:0}
  .subtitle{color:#8b949e;font-size:14px;margin-bottom:28px}
  .stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:32px}
  .stat{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:14px 18px;min-width:140px;flex:1}
  .stat .val{font-size:22px;font-weight:700;color:#58a6ff}
  .stat .label{font-size:12px;color:#8b949e;margin-top:2px}
  h2{font-size:16px;font-weight:600;margin:28px 0 12px;color:#e6edf3;border-bottom:1px solid #21262d;padding-bottom:8px}
  .endpoint{background:#161b22;border:1px solid #21262d;border-radius:8px;margin-bottom:12px;overflow:hidden}
  .ep-header{padding:12px 16px;display:flex;align-items:center;gap:10px;cursor:pointer}
  .ep-header:hover{background:#1c2129}
  .method{font-size:11px;font-weight:700;padding:3px 8px;border-radius:4px;font-family:monospace}
  .method.get{background:#0d2818;color:#3fb950}
  .method.post{background:#1c1e3a;color:#a371f7}
  .ep-path{font-family:monospace;font-size:14px;color:#e6edf3}
  .ep-desc{color:#8b949e;font-size:13px;margin-left:auto}
  .ep-body{padding:0 16px 14px;display:none}
  .ep-body.open{display:block}
  table{width:100%;border-collapse:collapse;margin-top:8px}
  th{text-align:left;font-size:11px;color:#8b949e;font-weight:600;padding:6px 8px;border-bottom:1px solid #21262d}
  td{font-size:13px;padding:6px 8px;border-bottom:1px solid #161b22;vertical-align:top}
  td:first-child{font-family:monospace;color:#79c0ff;white-space:nowrap}
  .example{background:#0d1117;border:1px solid #21262d;border-radius:6px;padding:10px 14px;font-family:monospace;font-size:13px;color:#8b949e;margin-top:10px;white-space:pre-wrap;word-break:break-all;position:relative}
  .example .kw{color:#ff7b72}
  .example .str{color:#a5d6ff}
  .try-link{font-size:12px;color:#58a6ff;text-decoration:none;margin-left:8px}
  .try-link:hover{text-decoration:underline}
  .footer{margin-top:40px;padding-top:16px;border-top:1px solid #21262d;color:#484f58;font-size:12px;display:flex;justify-content:space-between}
  .ai-examples{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
  .ai-q{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:10px 14px;font-size:14px;color:#e6edf3;display:flex;align-items:center;gap:10px}
  .ai-q .ai-icon{color:#d2a8ff;font-size:16px;flex-shrink:0}
  a{color:#58a6ff;text-decoration:none}
  a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="container">
  <h1><span class="dot"></span> JLCPCB Component Search API</h1>
  <p class="subtitle">Shared service for searching the JLCPCB/LCSC component catalog. Backed by a local SQLite database updated daily from <a href="https://github.com/cdfer/jlcpcb-parts-database">cdfer/jlcpcb-parts-database</a>.</p>

  <div class="stats">
    <div class="stat"><div class="val">${n(health.total_components)}</div><div class="label">Total components</div></div>
    <div class="stat"><div class="val">${n(health.in_stock)}</div><div class="label">In stock</div></div>
    <div class="stat"><div class="val">${health.fts_table === 'search_fts' ? 'FTS5' : 'LIKE'}</div><div class="label">Search engine</div></div>
    <div class="stat"><div class="val">Daily</div><div class="label">Update frequency</div></div>
  </div>

  <h2>Endpoints</h2>

  <div class="endpoint">
    <div class="ep-header" onclick="toggle(this)">
      <span class="method get">GET</span>
      <span class="ep-path">/search</span>
      <span class="ep-desc">Full-text component search</span>
    </div>
    <div class="ep-body">
      <table>
        <tr><th>Param</th><th>Type</th><th>Description</th></tr>
        <tr><td>query</td><td>string</td><td>Search term — part number, MFR, description, or LCSC code (e.g. <code>C14663</code>)</td></tr>
        <tr><td>limit</td><td>int</td><td>Max results (default 20, max 100)</td></tr>
        <tr><td>package</td><td>string</td><td>Filter by package (e.g. <code>0402</code>, <code>LQFP-48</code>)</td></tr>
        <tr><td>is_basic</td><td>bool</td><td>Only JLCPCB basic parts</td></tr>
        <tr><td>is_preferred</td><td>bool</td><td>Only JLCPCB preferred parts</td></tr>
      </table>
      <div class="example">curl "${api}/search?query=STM32&amp;limit=5"</div>
    </div>
  </div>

  <div class="endpoint">
    <div class="ep-header" onclick="toggle(this)">
      <span class="method get">GET</span>
      <span class="ep-path">/list</span>
      <span class="ep-desc">List components by subcategory</span>
    </div>
    <div class="ep-body">
      <table>
        <tr><th>Param</th><th>Type</th><th>Description</th></tr>
        <tr><td>subcategory</td><td>string</td><td>Exact subcategory name from /categories</td></tr>
        <tr><td>search</td><td>string</td><td>Filter within subcategory</td></tr>
        <tr><td>package</td><td>string</td><td>Filter by package</td></tr>
        <tr><td>limit</td><td>int</td><td>Max results (default 20, max 100)</td></tr>
        <tr><td>is_basic</td><td>bool</td><td>Only basic parts</td></tr>
        <tr><td>is_preferred</td><td>bool</td><td>Only preferred parts</td></tr>
      </table>
      <div class="example">curl "${api}/list?subcategory=Microcontrollers%20(MCU/MPU/SOC)&amp;limit=5"</div>
    </div>
  </div>

  <div class="endpoint">
    <div class="ep-header" onclick="toggle(this)">
      <span class="method get">GET</span>
      <span class="ep-path">/category_search</span>
      <span class="ep-desc">Search within a named category</span>
    </div>
    <div class="ep-body">
      <table>
        <tr><th>Param</th><th>Type</th><th>Description</th></tr>
        <tr><td>category</td><td>string</td><td>Category key: <code>resistors</code>, <code>capacitors</code>, <code>microcontrollers</code>, <code>mosfets</code>, <code>leds</code>, etc.</td></tr>
        <tr><td>filters</td><td>JSON</td><td>Optional filters object: <code>{"package":"0402","is_basic":true}</code></td></tr>
        <tr><td>limit</td><td>int</td><td>Max results (default 20, max 100)</td></tr>
      </table>
      <div class="example">curl "${api}/category_search?category=capacitors&amp;limit=5"</div>
    </div>
  </div>

  <div class="endpoint">
    <div class="ep-header" onclick="toggle(this)">
      <span class="method get">GET</span>
      <span class="ep-path">/categories</span>
      <span class="ep-desc">List all categories and subcategories</span>
    </div>
    <div class="ep-body">
      <p style="color:#8b949e;font-size:13px;margin-top:4px">Returns a hierarchy of category &rarr; subcategory with component counts.</p>
      <div class="example">curl "${api}/categories"</div>
    </div>
  </div>

  <div class="endpoint">
    <div class="ep-header" onclick="toggle(this)">
      <span class="method get">GET</span>
      <span class="ep-path">/health</span>
      <span class="ep-desc">Service health check</span>
    </div>
    <div class="ep-body">
      <p style="color:#8b949e;font-size:13px;margin-top:4px">Returns JSON with <code>ok</code>, component counts, FTS status, and DB path.</p>
      <div class="example">curl "${api}/health"</div>
    </div>
  </div>

  <h2>AI Usage</h2>
  <p style="color:#8b949e;font-size:13px;margin-bottom:12px">Adom containers with Adom Viewer installed have this service available as an MCP tool. Just ask Claude in natural language:</p>
  <div class="ai-examples">
    <div class="ai-q"><span class="ai-icon">&#9671;</span> Find me a USB-C connector that's a JLCPCB basic part</div>
    <div class="ai-q"><span class="ai-icon">&#9671;</span> Search JLCPCB for STM32 microcontrollers in LQFP-48 package</div>
    <div class="ai-q"><span class="ai-icon">&#9671;</span> Look up LCSC part C14663 — what is it and how much does it cost?</div>
    <div class="ai-q"><span class="ai-icon">&#9671;</span> I need a 3.3V LDO regulator on JLCPCB, show me the cheapest basic parts</div>
    <div class="ai-q"><span class="ai-icon">&#9671;</span> Compare 0402 100nF capacitors on JLCPCB — show me basic parts sorted by price</div>
  </div>

  <h2>POST API</h2>
  <p style="color:#8b949e;font-size:13px;margin-bottom:12px">All endpoints also accept POST with JSON body. Set <code>action</code> to the endpoint name.</p>
  <div class="example"><span class="kw">curl</span> -X POST ${api} \\
  -H <span class="str">"Content-Type: application/json"</span> \\
  -d <span class="str">'{"action":"search","query":"ESP32","limit":5}'</span></div>

  <h2>Response Format</h2>
  <p style="color:#8b949e;font-size:13px;margin-bottom:8px">Each component in the results includes:</p>
  <table>
    <tr><th>Field</th><th>Description</th></tr>
    <tr><td>lcsc</td><td>LCSC part number (e.g. C14663)</td></tr>
    <tr><td>mfr</td><td>Manufacturer part number</td></tr>
    <tr><td>description</td><td>Component description</td></tr>
    <tr><td>package</td><td>Package type (0402, LQFP-48, etc.)</td></tr>
    <tr><td>stock</td><td>Current stock quantity</td></tr>
    <tr><td>is_basic</td><td>JLCPCB basic part (lowest assembly fee)</td></tr>
    <tr><td>is_preferred</td><td>JLCPCB preferred part</td></tr>
    <tr><td>unit_price_usd</td><td>Unit price in USD</td></tr>
    <tr><td>price_tiers</td><td>Array of quantity-based pricing tiers</td></tr>
    <tr><td>popularity</td><td>Stock-based tier: very_high, high, medium, low, very_low</td></tr>
    <tr><td>datasheet</td><td>Datasheet URL (when available)</td></tr>
    <tr><td>jlcpcb_parts_url</td><td>Link to JLCPCB parts page</td></tr>
    <tr><td>attributes</td><td>Key-value component attributes (when available)</td></tr>
  </table>

  <div class="footer">
    <span>JLCPCB Component Search Service &middot; Adom</span>
    <span>Data: <a href="https://github.com/cdfer/jlcpcb-parts-database">cdfer/jlcpcb-parts-database</a></span>
  </div>
</div>
<script>
function toggle(el){el.nextElementSibling.classList.toggle('open')}
// Auto-open first endpoint
document.querySelector('.ep-body').classList.add('open');
</script>
</body>
</html>`;
}

// ── HTTP Server ─────────────────────────────────────────────────
const server = createServer(async (req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');

  if (req.method === 'OPTIONS') {
    res.writeHead(204);
    res.end();
    return;
  }

  let action, params;

  if (req.method === 'POST') {
    try {
      const body = await new Promise((resolve, reject) => {
        let data = '';
        req.on('data', chunk => data += chunk);
        req.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
        req.on('error', reject);
      });
      action = body.action;
      params = body;
      delete params.action;
    } catch {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Invalid JSON body' }));
      return;
    }
  } else if (req.method === 'GET') {
    const url = new URL(req.url, `http://localhost:${PORT}`);
    const path = url.pathname.replace(/^\//, '');

    // Serve HTML landing page at root for browsers
    if (!path && req.headers.accept?.includes('text/html')) {
      const health = handleHealth();
      // Derive public base URL from request headers (proxy sets Host / X-Forwarded-*)
      const proto = req.headers['x-forwarded-proto'] || 'https';
      const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${PORT}`;
      const prefix = req.headers['x-forwarded-prefix'] || '';
      const baseUrl = host.includes('localhost') || host.includes('127.0.0.1')
        ? `http://${host}${prefix}`
        : `${proto}://${host}${prefix}`;
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(landingPage(health, baseUrl));
      return;
    }

    action = path || 'health';
    params = Object.fromEntries(url.searchParams);
  } else {
    res.writeHead(405, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Method not allowed' }));
    return;
  }

  try {
    let result;
    switch (action) {
      case 'search':
        result = handleSearch(params);
        break;
      case 'list':
        result = handleListComponents(params);
        break;
      case 'category_search':
        result = handleCategorySearch(params);
        break;
      case 'categories':
        result = handleCategories();
        break;
      case 'health':
        result = handleHealth();
        break;
      default:
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: `Unknown action: ${action}` }));
        return;
    }
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(result));
  } catch (err) {
    console.error(`[jlcpcb] Error handling ${action}:`, err.message);
    res.writeHead(500, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: err.message }));
  }
});

server.listen(PORT, '0.0.0.0', () => {
  console.log(`[jlcpcb] JLCPCB search server listening on 0.0.0.0:${PORT}`);
  console.log(`[jlcpcb] Source: local SQLite (${DB_PATH})`);
  console.log(`[jlcpcb] FTS: ${ftsTable || 'none (LIKE fallback)'}`);
});