123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008
#!/usr/bin/env node
'use strict';
/*
 * chip-fetcher-lite — backend
 *
 * Pure-Node http server (no native deps). Serves the SPA and a small REST API.
 * Storage is a single JSON file + a staging dir for uploaded files.
 *
 *   GET  /api/state                       -> { components, probe, jobs }
 *   POST /api/components        {name}     -> add a row (status "checking")
 *   POST /api/components/:id/check         -> run wiki lookup + file detection
 *   POST /api/components/:id/select {on}   -> toggle row highlight
 *   POST /api/select-all        {on}       -> toggle every row
 *   POST /api/components/:id/upload?kind=&filename=  (raw body) -> stage a file
 *   DELETE /api/components/:id
 *   POST /api/probe-request     {edas}     -> queue a library-probe job for the agent
 *   POST /api/probe             {edas, libraries}  -> agent posts probe results
 *   POST /api/send-to-library   {ids, edas}-> queue a send job for the agent
 *   GET  /api/jobs                         -> pending agent jobs
 *   POST /api/jobs/:id/complete {result}   -> agent marks a job done
 */

const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { execFile } = require('child_process');

const ROOT = __dirname;
const DATA_DIR = process.env.CFL_DATA_DIR || path.join(ROOT, 'data');
const DATA_FILE = path.join(DATA_DIR, 'data.json');
const STAGING_DIR = path.join(DATA_DIR, 'staging');
const PUBLIC_DIR = path.join(ROOT, 'public');
const PORT = parseInt(process.env.PORT || process.env.CFL_PORT || '7821', 10);
const WIKI = process.env.CFL_WIKI || 'https://wiki.adom.inc';

fs.mkdirSync(STAGING_DIR, { recursive: true });

// ---------------------------------------------------------------- data store
function load() {
  try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
  catch { return { components: [], probe: { edas: [], libraries: {}, probedAt: null }, jobs: [] }; }
}
let DB = load();
if (!DB.send) DB.send = { status: 'idle', total: 0, done: 0, current: '', errors: [] };
let saveTimer = null;
function save() {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(() => {
    fs.mkdirSync(DATA_DIR, { recursive: true });
    fs.writeFileSync(DATA_FILE, JSON.stringify(DB, null, 2));
  }, 50);
}
function uid() { return crypto.randomBytes(8).toString('hex'); }
function find(id) { return DB.components.find(c => c.id === id); }

// bounded wiki-check queue so mass-adds don't spawn hundreds of `adompkg` processes at once
const CHECK_CONCURRENCY = 6;   // tuned from the 138-part stress test: fast without overloading adompkg
let _checkQueue = [], _checkActive = 0;
function enqueueCheck(id) { _checkQueue.push(id); pumpChecks(); }
function pumpChecks() {
  while (_checkActive < CHECK_CONCURRENCY && _checkQueue.length) {
    const c = find(_checkQueue.shift()); if (!c) continue;
    _checkActive++;
    runCheck(c).catch(e => { c.wiki.status = 'error'; c.type = 'IC / Other'; c.error = String(e && e.message || e); })
      .finally(() => { _checkActive--; save(); pumpChecks(); });
  }
}

// ---------------------------------------------------------------- wiki logic
//  (ported from chip-fetcher-wiki-flow / cf-wiki-lookup.py)
function norm(mpn) {
  return mpn.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
const SUFFIX = /(tr|t6|t7|tb|tg|ar|ag|-?reel|-?cut|-?tape)$/i;
function stem(slug) { return (slug.replace(SUFFIX, '').replace(/^-+|-+$/g, '')) || slug; }
const PASSIVE_HINT = /\b(0201|0402|0603|0805|1206|1210|resistor|capacitor|mlcc|inductor|ferrite|led)\b/i;
function sharedPrefix(a, b) { let n = 0; const L = Math.min(a.length, b.length); while (n < L && a[n] === b[n]) n++; return n; }

// `adompkg search` -> [{slug,type,title,desc}]
function adompkgSearch(q) {
  return new Promise((resolve) => {
    execFile('adompkg', ['search', q], { timeout: 45000, maxBuffer: 4 << 20, env: process.env },
      (err, stdout) => {
        if (err && !stdout) return resolve([]);
        const rows = []; let cur = null;
        const ENTRY = /^(\S+)\s{2,}([A-Za-z][\w-]*)\s{2,}(.*)$/;
        for (const line of (stdout || '').split('\n')) {
          const m = ENTRY.exec(line);
          if (m) { cur = { slug: m[1], type: m[2], title: m[3].trim(), desc: '' }; rows.push(cur); }
          else if (line.trim() && cur && /^\s/.test(line)) cur.desc = (cur.desc + ' ' + line.trim()).trim();
        }
        resolve(rows);
      });
  });
}

// the MPN is the head of a result title, e.g. "0402B104J160CT — 100nF MLCC Capacitor" -> "0402B104J160CT"
function titleMpn(r) {
  const t = (r.title || '').trim();
  return (t.split(/\s+[—–-]\s+/)[0].trim()) || t.split(/\s+/)[0] || r.slug;
}
// pull a value (100nF, 10k, 4.7uF) + package (0402…) out of a descriptive passive string
function passiveTokens(s) {
  const pkg = (s.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/) || [])[0] || null;
  const vm = s.match(/(\d+(?:\.\d+)?)\s*([pnuµμmkKMG]?)\s*(f|h|r|ω|ohm|farad|henry)?\b/i);
  let val = null;
  if (vm && /[a-zµμω]/i.test((vm[2] || '') + (vm[3] || ''))) // require a unit/prefix so we don't grab package digits
    val = (vm[1] + (vm[2] || '') + (vm[3] || '')).toLowerCase().replace(/[µμ]/g, 'u');
  return { pkg, val };
}
// --- Generic-part resolution. Wiki component pages are keyed by MPN ONLY. For resistors the
// resistance is encoded INSIDE the MPN (e.g. "...1001..." = 100×10^1 = 1kΩ) and NEVER appears as
// human-readable "1k" in the page title/desc — so a description search ("1k 0402 resistor") can't
// text-match a resistor page (FTS just returns whatever mentions "0402"). We must decode the value
// to its EIA code(s) and match those against candidate MPNs, then verify package via page metadata.
// (Capacitors usually DO carry "100nF" in the title so they resolve by text; this is the fallback.)
function ohmsFromToken(val) {
  const m = String(val || '').toLowerCase().replace(/ohm|Ω|ω/g, '').match(/^(\d*\.?\d+)([rkmg]?)$/);
  if (!m) return null;
  const mult = { '': 1, r: 1, k: 1e3, m: 1e6, g: 1e9 }[m[2]];
  const n = parseFloat(m[1]);
  return (isFinite(n) && mult != null) ? n * mult : null;
}
// EIA value codes for a resistance, most-specific (4-digit/1%) first: 1000Ω -> ["1001", "102"]
function eiaResistorCodes(ohms) {
  if (!(ohms > 0)) return [];
  const out = [];
  for (const sig of [3, 2]) {                       // 4-digit (3 sig figs) then 3-digit (2 sig figs)
    let exp = Math.floor(Math.log10(ohms)) - (sig - 1);
    let mant = Math.round(ohms / Math.pow(10, exp));
    if (mant >= Math.pow(10, sig)) { mant = Math.round(mant / 10); exp++; }
    if (exp >= 0 && mant >= Math.pow(10, sig - 1)) out.push(String(mant) + String(exp));
  }
  return out;
}
// 3-digit pF code for a capacitance string ("100nf" -> "104", "4.7uf" -> "475", "10pf" -> "100")
function pfCapCode(val) {
  const m = String(val || '').toLowerCase().match(/^(\d*\.?\d+)(p|n|u|µ)?f?$/);
  if (!m) return null;
  const pf = parseFloat(m[1]) * ({ p: 1, n: 1e3, u: 1e6, 'µ': 1e6, '': 1 }[m[2] || '']);
  if (!(pf > 0)) return null;
  const exp = Math.max(0, Math.floor(Math.log10(pf)) - 1);
  return String(Math.round(pf / Math.pow(10, exp))) + String(exp);
}

async function classify(mpn) {
  const slug = norm(mpn);
  let rows = await adompkgSearch(mpn);
  const st = stem(slug);
  if (st !== slug) {
    const seen = new Set(rows.map(r => r.slug));
    const more = await adompkgSearch(st);
    rows = rows.concat(more.filter(r => !seen.has(r.slug)));
  }
  const comps = rows.filter(r => r.type === 'component');
  const passive = !!(PASSIVE_HINT.test(mpn) || comps.some(r => PASSIVE_HINT.test(r.desc)));

  // 1. exact: slug, the MPN parsed from a result title, or a title equal to the input
  let exact = comps.find(r => r.slug === slug || norm(titleMpn(r)) === slug || r.title.toLowerCase() === mpn.toLowerCase()) || null;

  // 2. description -> MPN. Wiki pages are keyed by MPN only, so a descriptive request
  //    ("1k 0402 resistor", "100nF 0402 capacitor") must be resolved to a real MPN page.
  if (!exact) {
    const tok = passiveTokens(mpn);
    const isR = /resistor/i.test(mpn), isC = /capacitor|mlcc/i.test(mpn);
    const descriptive = (tok.pkg && tok.val) || isR || isC || /inductor|\bled\b|ferrite|diode/i.test(mpn);
    if (descriptive) {
      const hay = r => (r.title + ' ' + r.desc).toLowerCase().replace(/\s+/g, '');
      const isPassive = r => /capacitor|resistor|inductor|\bled\b|ferrite|diode|mlcc|oscillator|crystal/i.test(r.title + ' ' + r.desc);
      // (a) text match — works for caps/LEDs whose title carries the human value ("100nF MLCC …")
      exact = comps.find(r => isPassive(r) && (!tok.val || hay(r).includes(tok.val)) && (!tok.pkg || hay(r).includes(tok.pkg))) || null;
      // (b) MPN-code match — resistors (and caps) encode the value IN the MPN, not the page text.
      //     FTS on the description often misses the family pages entirely, so search by
      //     type+package, match the decoded EIA value code in the MPN, then VERIFY package via meta.
      if (!exact && tok.pkg && tok.val && (isR || isC)) {
        const typeWord = isR ? 'resistor' : 'capacitor';
        const codes = isR ? eiaResistorCodes(ohmsFromToken(tok.val)) : [pfCapCode(tok.val)].filter(Boolean);
        if (codes.length) {
          const seen = new Set(comps.map(r => r.slug));
          const extra = (await adompkgSearch(`${typeWord} ${tok.pkg}`)).filter(r => r.type === 'component' && !seen.has(r.slug));
          const pool = comps.concat(extra);
          const idOf = r => (r.slug + ' ' + r.title).toLowerCase().replace(/[^a-z0-9]/g, '');
          const matchCat = r => new RegExp(typeWord, 'i').test(r.title + ' ' + r.desc);
          const cands = [];
          for (const code of codes) for (const r of pool)   // 4-digit (specific) candidates first
            if (matchCat(r) && idOf(r).includes(code) && !cands.includes(r)) cands.push(r);
          for (const r of cands.slice(0, 8)) {              // confirm the package matches the page
            const pm = await wikiJSON(`/api/v1/pages/${r.slug}`);
            const pkg = pm && pm.metadata && String(pm.metadata.package || '').toLowerCase();
            if (pkg === tok.pkg) { exact = r; break; }
          }
        }
      }
    }
  }

  let variants = [];
  if (!exact) {
    const thresh = Math.max(6, Math.floor(slug.length * 0.6));
    for (const r of comps) {
      const sh = sharedPrefix(r.slug, slug);
      if (r.slug !== slug && (sh >= thresh || stem(r.slug) === st)) variants.push({ ...r, shared: sh });
    }
    variants.sort((a, b) => b.shared - a.shared);
    variants = variants.slice(0, 6);
  }
  const status = exact ? 'exact' : (variants.length ? 'variant' : 'none');
  return { slug, status, passive, exact, variants, resolvedMpn: exact ? titleMpn(exact) : null };
}

async function wikiJSON(p) {
  try {
    const res = await fetch(WIKI + p, { signal: AbortSignal.timeout(20000) });
    if (!res.ok) return null;
    return await res.json();
  } catch { return null; }
}

// Map a filename to an EDA bucket + kind
const EXT = [
  [/\.kicad_sym$/i,                 'kicad',  'symbol'],
  [/\.kicad_mod$/i,                 'kicad',  'footprint'],
  [/\.kicad_pcb$/i,                 'kicad',  'footprint'],
  [/\.pretty$/i,                    'kicad',  'footprint'],
  [/\.schlib$/i,                    'altium', 'symbol'],
  [/\.pcblib$/i,                    'altium', 'footprint'],
  [/\.intlib$/i,                    'altium', 'library'],
  [/\.lbr$/i,                       'fusion', 'library'],
  [/\.f3[dz]$/i,                    'fusion', 'library'],
  [/\.(step|stp|glb|stl|wrl)$/i,    '3d',     'model3d'],
];
function classifyFile(name) {
  for (const [re, eda, kind] of EXT) if (re.test(name)) return { eda, kind };
  return null;
}

// derive a grouping "type" for a component
function deriveType(meta, passive, mpn) {
  if (meta && meta.category && meta.category !== 'library' && meta.category !== 'other')
    return meta.category;
  const s = (mpn + ' ' + (meta && meta.package || '')).toLowerCase();
  if (/resistor/.test(s)) return 'Resistor';
  if (/capacitor|mlcc/.test(s)) return 'Capacitor';
  if (/inductor|ferrite/.test(s)) return 'Inductor';
  if (/\bled\b/.test(s)) return 'LED';
  if (/crystal|oscillator|mhz|khz/.test(s)) return 'Crystal / Oscillator';
  if (passive) return 'Passive';
  if (meta && meta.category) return meta.category === 'library' ? 'IC / Other' : meta.category;
  return 'IC / Other';
}

// ---- build the symbol's Description field (key specs per component type)
const POWER_BY_SIZE = { '0201': '1/20W', '0402': '1/16W', '0603': '1/10W', '0805': '1/8W', '1206': '1/4W', '1210': '1/2W', '2010': '3/4W', '2512': '1W' };
function sizeOf(c) { return c.package || (`${c.name || ''} ${c.mpn || ''}`.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/) || [])[1] || null; }
function pick(re, txt) { const m = (txt || '').match(re); return m ? (m[1] || m[0]).replace(/\s+/g, '') : null; }
function buildSymbolDescription(c, brief) {
  const t = `${c.type || ''} ${c.subcategory || ''}`;
  const txt = [brief, c.name, c.mpn].filter(Boolean).join(' ');
  const size = sizeOf(c);
  const tol = pick(/[±+]\s*\d+(?:\.\d+)?\s*%/, txt);
  if (/capacitor|mlcc/i.test(t)) {
    const val = pick(/\d+(?:\.\d+)?\s*[pnuµμm]?F\b/i, txt);
    const volt = pick(/\d+(?:\.\d+)?\s*V\b/i, txt);
    const diel = pick(/\b(?:C0G|NP0|X7R|X5R|X7S|X6S|Y5V|Z5U)\b/i, txt);
    return [val, size, volt, diel, tol].filter(Boolean).join(', ');
  }
  if (/resistor/i.test(t)) {
    const val = pick(/\d+(?:\.\d+)?\s*[kKMRGΩ]\d*(?:\s*(?:ohm|Ω))?\b/i, txt) || pick(/\b\d+(?:\.\d+)?[kKMR]\b/, `${c.name} ${c.mpn}`);
    const pwr = pick(/\d+\/\d+\s*W\b|\d+(?:\.\d+)?\s*W\b/i, txt) || (size && POWER_BY_SIZE[size]) || null;
    return [val, size, pwr, tol].filter(Boolean).join(', ');
  }
  if (/inductor|ferrite|choke/i.test(t)) {
    const val = pick(/\d+(?:\.\d+)?\s*[pnuµμm]?H\b/i, txt);
    const cur = pick(/\d+(?:\.\d+)?\s*m?A\b/i, txt);
    return [val, size, cur].filter(Boolean).join(', ') || ['Inductor', size].filter(Boolean).join(', ');
  }
  if (/\bled\b/i.test(t)) {
    const color = pick(/\b(?:red|green|blue|white|yellow|amber|orange|RGB)\b/i, txt);
    const volt = pick(/\d+(?:\.\d+)?\s*V\b/i, txt);
    return [color, size, volt].filter(Boolean).join(', ') || ['LED', size].filter(Boolean).join(', ');
  }
  if (/diode|rectifier|zener|schottky/i.test(t)) {
    const volt = pick(/\d+(?:\.\d+)?\s*V\b/i, txt);
    const cur = pick(/\d+(?:\.\d+)?\s*m?A\b/i, txt);
    return ['Diode', size, volt, cur].filter(Boolean).join(', ');
  }
  // non-passives: comma-separated key specs
  const pkgShort = (c.package || '').split(/[ ,(]/)[0] || c.package;
  const parts = [c.manufacturer, pkgShort, c.pinCount ? `${c.pinCount}-pin` : null, c.subcategory].filter(Boolean);
  if (parts.length >= 2) return parts.join(', ');
  if (brief) return brief.split(/\s+[—–-]\s+/)[0].trim().slice(0, 120);
  return parts.join(', ') || c.type;
}

// ---- per-EDA, per-file-kind presence model -----------------------------------
// Each component tracks, for KiCad/Altium/Fusion independently, whether a
// symbol / footprint / 3D model is available (and whether it came from the wiki
// or a user upload). Each EDA box colours on its own: red=0, yellow=1-2, green=3.
const EDAS = ['kicad', 'altium', 'fusion'];
const KINDS = ['symbol', 'footprint', 'model3d'];
function blankKind() { return { present: false, source: null }; }
function blankEda() { return { symbol: blankKind(), footprint: blankKind(), model3d: blankKind() }; }
function freshEda() { return { kicad: blankEda(), altium: blankEda(), fusion: blankEda() }; }

// apply one classified file to the eda model (uploads override wiki)
function applyFile(c, eda, kind, source) {
  const set = (e, k) => { const cur = c.eda[e][k]; if (cur.source !== 'upload' || source === 'upload') c.eda[e][k] = { present: true, source }; };
  if (kind === 'library') { set(eda, 'symbol'); set(eda, 'footprint'); }      // .IntLib / .lbr = symbol + footprint
  else if (kind === 'model3d') { for (const e of EDAS) set(e, 'model3d'); }   // a STEP/GLB serves any EDA
  else if (EDAS.includes(eda)) set(eda, kind);
}

// rebuild c.eda from the last wiki check (c._wiki) + current uploads
function recomputeEda(c) {
  c.eda = freshEda();
  const w = c._wiki || { files: [], has3d: false };
  for (const name of w.files) { const hit = classifyFile(name); if (hit) applyFile(c, hit.eda, hit.kind, 'wiki'); }
  if (w.has3d) for (const e of EDAS) if (!c.eda[e].model3d.present) c.eda[e].model3d = { present: true, source: 'wiki' };
  for (const u of (c.uploads || [])) { const hit = classifyFile(u.name); if (hit) applyFile(c, hit.eda, hit.kind, 'upload'); }
  c.wikiFiles = { '3d': EDAS.some(e => c.eda[e].model3d.present && c.eda[e].model3d.source === 'wiki') };
}

// Run the full wiki check for one component and merge results into its record.
async function runCheck(c) {
  const cls = await classify(c.mpn);
  // a descriptive request ("100nF 0402 capacitor") resolves to a real MPN page — adopt the MPN
  if (cls.resolvedMpn && cls.resolvedMpn !== c.mpn) {
    // if that MPN is already in the list, this entry is a duplicate — drop it
    const other = DB.components.find(x => x.id !== c.id && (x.slug === norm(cls.resolvedMpn) || norm(x.mpn) === norm(cls.resolvedMpn)));
    if (other) { DB.components = DB.components.filter(x => x.id !== c.id); return c; }
    const old = c.mpn;
    c.mpn = cls.resolvedMpn; c.name = cls.resolvedMpn;
    if (c.names) {
      if (c.names.symbol === old) c.names.symbol = cls.resolvedMpn;
      if (c.names.footprint === old && !c.footprintLocked) c.names.footprint = cls.resolvedMpn;
    }
  }
  c.slug = cls.exact ? cls.exact.slug : norm(c.mpn);
  c.passive = cls.passive;
  c.wiki = { status: cls.status, slug: null, variants: cls.variants || [], checkedAt: new Date().toISOString() };

  let meta = null, brief = '';
  c._wiki = { files: [], has3d: false };
  const resolvedSlug = cls.exact ? cls.exact.slug : null;
  if (resolvedSlug) {
    c.wiki.slug = resolvedSlug;
    const pageMeta = await wikiJSON(`/api/v1/pages/${resolvedSlug}`);
    meta = pageMeta && pageMeta.metadata;
    brief = (pageMeta && pageMeta.page && pageMeta.page.brief) || '';
    c.wiki.owner = (pageMeta && pageMeta.page && pageMeta.page.owner) || null;
    c.wiki.url = c.wiki.owner ? `${WIKI}/${c.wiki.owner}/${resolvedSlug}` : `${WIKI}/${resolvedSlug}`;
    const filesResp = await wikiJSON(`/api/v1/pages/${resolvedSlug}/files`);
    const files = (filesResp && filesResp.files) || [];
    c._wiki.files = files.map(f => f.name);
    if (meta && meta.has_3d_model) c._wiki.has3d = true;
  }
  c.type = deriveType(meta, c.passive, c.mpn);
  c.manufacturer = (meta && meta.manufacturer) || c.manufacturer || null;
  c.package = (meta && meta.package) || c.package || null;
  c.subcategory = (meta && meta.subcategory) || null;
  c.pinCount = (meta && meta.pin_count) || null;
  c.jlcPart = (meta && meta.jlcpcb_part) || c.jlcPart || null;   // keep a manually-set PN through re-checks
  c.jlc = !!c.jlcPart;
  const dsPath = meta && meta.datasheet_path;
  c.datasheetUrl = dsPath ? `${WIKI}/blob/component/${resolvedSlug}/${dsPath}` : (c.datasheetUrl || null);
  c.description = buildSymbolDescription(c, brief);  // goes in the symbol's Description field
  recomputeEda(c);
  // once we know the manufacturer, refine the destination (esp. the 3D manufacturer folder)
  if (DB.probe && DB.probe.libraries && Object.keys(DB.probe.libraries).length) applyDest(c);
  return c;
}

// ---------------------------------------------------------------- library probe
// The user's libraries live on THEIR machine, reached via the adom-desktop bridge.
// The server shells to `adom-desktop` to read the user's KiCad/Altium/Fusion
// library locations, then fills each component's symbol/footprint/3D destination.
const HOME = os.homedir();
const EDA_EXTS = { kicad: /\.(kicad_sym|kicad_mod|pretty)$/i, altium: /\.(schlib|pcblib|intlib)$/i, fusion: /\.lbr$/i };

function nameDest(p) { if (!p) return null; const name = p.split(/[\\/]/).filter(Boolean).pop() || p; return { name, path: p }; }
const dirOf = p => (p || '').replace(/[\\/][^\\/]*$/, '');

// Run a discovery shell command on the user's machine via adom-desktop.
function runDesktopShell(command, timeoutSeconds = 90) {
  return new Promise((resolve) => {
    execFile('adom-desktop', ['shell_execute', JSON.stringify({ command, timeoutSeconds })],
      { timeout: (timeoutSeconds + 15) * 1000, maxBuffer: 32 << 20, env: process.env }, (err, stdout) => {
        let out = '';
        try { out = JSON.parse(stdout || '{}').stdout || ''; } catch { out = stdout || ''; }
        resolve(out);
      });
  });
}

// Probe the USER's machine (via adom-desktop) for their own EDA libraries.
// KiCad: enumerate the actual .kicad_sym FILES, .pretty FOLDERS, and the 3D
// manufacturer SUBFOLDERS — NOT the KiCad stock/default libraries.
async function probeUserLibraries(edas) {
  const sel = (edas && edas.length) ? edas : ['kicad', 'altium', 'fusion'];
  const cmd = [
    'D="$HOME/Documents"',
    'find "$D" -iname "*.kicad_sym" 2>/dev/null | grep -vi backup | head -200 | sed "s|^|KSYM=|"',
    'find "$D" -type d -iname "*.pretty" 2>/dev/null | grep -vi backup | head -200 | sed "s|^|KFP=|"',
    'for d in "$D/KiCAD/3D" "$D/KiCad/3D" "$D/KiCAD/3d" "$D/KiCad/3d"; do [ -d "$d" ] && echo "K3D=$d"; done',
    'for d in "$D/KiCAD/3D" "$D/KiCad/3D"; do [ -d "$d" ] && find "$d" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -300 | sed "s|^|K3DSUB=|"; done',
    'find "$D" -iname "*.lbr" 2>/dev/null | grep -vi backup | head -80 | sed "s|^|FUS=|"',
    'find "$D" \\( -iname "*.SchLib" -o -iname "*.PcbLib" -o -iname "*.IntLib" \\) 2>/dev/null | head -80 | sed "s|^|ALT=|"',
    // KiCad STOCK/default footprint libraries (passives & basics) — OS-specific share paths
    'for s in /usr/share/kicad /usr/local/share/kicad /snap/kicad/current/usr/share/kicad "/var/lib/flatpak/app/org.kicad.KiCad/current/active/files/share/kicad" "$HOME/.local/share/flatpak/app/org.kicad.KiCad/current/active/files/share/kicad" "/Applications/KiCad/KiCad.app/Contents/SharedSupport"; do for lib in Capacitor_SMD Resistor_SMD Inductor_SMD LED_SMD Capacitor_THT Resistor_THT; do dd="$s/footprints/$lib.pretty"; if [ -d "$dd" ]; then echo "KDEFLIB=$lib|$dd"; ls "$dd" 2>/dev/null | grep -i "\\.kicad_mod$" | head -500 | sed "s|^|KDEFFP=$lib/|"; fi; done; done',
  ].join('; ');
  const out = await runDesktopShell(cmd);
  const lines = out.split('\n').map(l => l.trim());
  const grab = pfx => lines.filter(l => l.startsWith(pfx)).map(l => l.slice(pfx.length)).filter(Boolean);
  const base = p => (p || '').split('/').pop();
  const ksym = grab('KSYM='), kfp = grab('KFP='), k3d = grab('K3D='), k3dsub = grab('K3DSUB='), fus = grab('FUS='), alt = grab('ALT=');
  const kdeflib = grab('KDEFLIB='), kdeffp = grab('KDEFFP=');
  const defaultFootprints = {};
  for (const e of kdeflib) { const i = e.indexOf('|'); if (i < 0) continue; const lib = e.slice(0, i), pth = e.slice(i + 1); if (lib && pth && !defaultFootprints[lib]) defaultFootprints[lib] = { path: pth, footprints: [] }; }
  for (const e of kdeffp) { const i = e.indexOf('/'); if (i < 0) continue; const lib = e.slice(0, i), fp = e.slice(i + 1).replace(/\.kicad_mod$/i, ''); if (defaultFootprints[lib]) defaultFootprints[lib].footprints.push(fp); }

  const libraries = {};
  if (sel.includes('kicad') && (ksym.length || kfp.length || Object.keys(defaultFootprints).length)) {
    libraries.kicad = {
      symbolsDir: dirOf(ksym[0] || ''),
      footprintsDir: kfp.length ? dirOf(kfp[0]) : '',
      models3dDir: k3d[0] || '',
      symbolFiles: ksym,                 // full paths to each .kicad_sym
      footprintDirs: kfp,                // full paths to each .pretty
      mfrFolders: k3dsub.map(p => ({ name: base(p), path: p })), // 3D manufacturer subfolders
      defaultFootprints,                 // KiCad stock footprint libs (Capacitor_SMD, etc.)
    };
  }
  if (sel.includes('fusion') && fus.length) libraries.fusion = { root: dirOf(fus[0]), files: fus.map(base) };
  if (sel.includes('altium') && alt.length) libraries.altium = { root: dirOf(alt[0]), files: alt.map(base) };
  return { libraries };
}

// ---- destination picking: JLC-category symbol lib, manufacturer footprint lib, manufacturer 3D folder
const baseNoExt = p => (p || '').split('/').pop().replace(/\.(kicad_sym|pretty)$/i, '');
const nrm = s => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
// manufacturer name -> tokens/abbreviations that may appear in folder/library names
const MFR_ALIASES = {
  'texas instruments': ['ti'], 'stmicroelectronics': ['st', 'stm'], 'nordic semiconductor': ['nordic', 'nrf'],
  'analog devices': ['adi', 'analog', 'maxim'], 'on semiconductor': ['onsemi', 'on'], 'onsemi': ['on'],
  'nxp semiconductors': ['nxp'], 'microchip technology': ['microchip', 'atmel'], 'würth elektronik': ['wurth', 'würth'],
  'wurth elektronik': ['wurth'], 'vishay': ['vishay'], 'murata': ['murata'], 'tdk': ['tdk'], 'bosch': ['bosch'], 'espressif': ['espressif', 'esp'],
};
const MFR_STOP = new Set(['technology', 'technologies', 'corporation', 'corp', 'semiconductor', 'semiconductors', 'electronics', 'electronic', 'inc', 'ltd', 'co', 'company', 'gmbh', 'components', 'component', 'international', 'industries', 'microelectronics', 'group', 'limited', 'systems']);
function mfrTokens(mfr) {
  if (!mfr) return [];
  const lower = mfr.toLowerCase();
  const toks = [nrm(mfr)];
  // expand aliases only when the FULL manufacturer name matches the alias key (no substring contamination)
  for (const [k, v] of Object.entries(MFR_ALIASES)) if (lower.includes(k)) toks.push(...v.map(nrm));
  toks.push(...lower.split(/[^a-z0-9]+/).filter(w => w.length >= 3 && !MFR_STOP.has(w)).map(nrm));
  return [...new Set(toks.filter(Boolean))];
}
// map a component to its JLC category-library name (their convention: JLC_<Cat>_BLT)
const JLC_CATEGORY = [
  [/capacitor|\bmlcc\b/i, 'Capacitors'], [/resistor/i, 'Resistors'], [/inductor|ferrite|\bchoke\b/i, 'Inductors'],
  [/\bled\b/i, 'LEDs'], [/diode|rectifier|zener|\btvs\b|schottky/i, 'Diodes'], [/crystal|oscillator|resonator/i, 'Oscillators'],
  [/\bfet\b|mosfet|transistor|\bbjt\b/i, 'FETs'], [/connector|header|socket|receptacle/i, 'Connectors'],
  [/\bflash\b|eeprom|\bsram\b|\bdram\b|\bram\b|memory/i, 'Flash'], [/regulator|\bldo\b|dc-dc|buck|boost|\bpmic\b|power management/i, 'Power'],
  [/microcontroller|\bmcu\b|\bsoc\b|processor|stm32|nrf\d|esp32|esp8266|atmega|attiny|rp2040|rp2350|\bpic\d|samd\d|msp430/i, 'MCUs'],
];
function jlcCategory(c) {
  const s = `${c.type || ''} ${c.subcategory || ''} ${c.mpn || ''}`;
  for (const [re, cat] of JLC_CATEGORY) if (re.test(s)) return cat;
  return 'Misc';
}
function matchByMfr(items, getName, mfr) { // items -> first whose name matches a manufacturer token
  const toks = mfrTokens(mfr);
  if (!toks.length) return null;
  for (const it of items) {
    const raw = getName(it) || '';
    const segs = raw.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).map(nrm); // e.g. "TI_BLT" -> ["ti","blt"]
    const full = nrm(raw);
    for (const t of toks) {
      if (segs.includes(t)) return it;                                  // exact segment (handles TI_BLT -> "ti")
      if (t.length >= 4 && full.length >= 4 && (full.includes(t) || t.includes(full))) return it; // fuzzy: both sides long
    }
  }
  return null;
}
// imperial -> metric size code; passive type -> (symbol prefix, KiCad stock lib)
const IMP_TO_METRIC = { '0201': '0603', '0402': '1005', '0603': '1608', '0805': '2012', '1206': '3216', '1210': '3225', '1812': '4532', '2010': '5025', '2512': '6332' };
const PASSIVE_FP = [[/capacitor|\bmlcc\b/i, 'C', 'Capacitor_SMD'], [/resistor/i, 'R', 'Resistor_SMD'], [/inductor|ferrite/i, 'L', 'Inductor_SMD'], [/\bled\b/i, 'LED', 'LED_SMD']];
// If this is a passive with a known size, return its KiCad STOCK footprint (non-editable).
function defaultFootprint(c, defLibs) {
  if (!defLibs) return null;
  const s = `${c.type || ''} ${c.subcategory || ''} ${c.mpn || ''} ${c.name || ''}`;
  const hit = PASSIVE_FP.find(([re]) => re.test(s)); if (!hit) return null;
  const [, prefix, libName] = hit;
  const sizem = `${c.name || ''} ${c.mpn || ''} ${c.package || ''}`.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/);
  if (!sizem) return null;
  const imp = sizem[1], met = IMP_TO_METRIC[imp]; if (!met) return null;
  const lib = defLibs[libName]; if (!lib) return null;
  const want = `${prefix}_${imp}_${met}Metric`;
  const fpName = (lib.footprints || []).includes(want) ? want : (lib.footprints || []).find(n => n.startsWith(want));
  if (!fpName) return null;
  return { name: `${libName}.pretty`, path: lib.path, source: 'kicad-default', exists: true, fpName };
}
function computeDest(c) {
  const lib = DB.probe && DB.probe.libraries;
  const k = lib && lib.kicad;
  if (k) {
    // SYMBOL -> JLC category library (.kicad_sym); create if missing
    const cat = jlcCategory(c);
    const jlcBase = `JLC_${cat}_BLT`;
    const symHit = (k.symbolFiles || []).find(p => baseNoExt(p).toLowerCase() === jlcBase.toLowerCase())
      || (k.symbolFiles || []).find(p => /jlc/i.test(p) && nrm(p).includes(nrm(cat).replace(/s$/, '')));
    const symbol = symHit ? { ...nameDest(symHit), exists: true }
      : { name: `${jlcBase}.kicad_sym`, path: `${k.symbolsDir}/${jlcBase}.kicad_sym`, exists: false };

    // FOOTPRINT -> prefer a footprint that's ON THE WIKI (file into the manufacturer library);
    // only fall back to the KiCad STOCK library for passives that are NOT on the wiki.
    const mfr = c.manufacturer || 'Misc';
    const hasWikiFp = c.eda && c.eda.kicad && c.eda.kicad.footprint && c.eda.kicad.footprint.present && c.eda.kicad.footprint.source === 'wiki';
    let footprint = hasWikiFp ? null : defaultFootprint(c, k.defaultFootprints);
    if (!footprint) {
      const fpHit = matchByMfr(k.footprintDirs || [], baseNoExt, c.manufacturer);
      footprint = fpHit ? { ...nameDest(fpHit), exists: true, source: 'manufacturer' }
        : { name: `${mfr}.pretty`, path: `${k.footprintsDir}/${mfr}.pretty`, exists: false, source: 'manufacturer' };
    }

    // 3D -> manufacturer subfolder (deepest); create if missing
    let model3d = null;
    if (k.models3dDir) {
      const mfHit = matchByMfr(k.mfrFolders || [], it => it.name, c.manufacturer);
      model3d = mfHit ? { ...nameDest(mfHit.path), exists: true }
        : { name: mfr, path: `${k.models3dDir}/${mfr}`, exists: false };
    }
    return { symbol, footprint, model3d };
  }
  const r = (lib && lib.fusion && lib.fusion.root) || (lib && lib.altium && lib.altium.root);
  return r ? { symbol: { ...nameDest(r), exists: true }, footprint: { ...nameDest(r), exists: true }, model3d: { ...nameDest(r), exists: true } } : { symbol: null, footprint: null, model3d: null };
}
// compute + apply the destination, locking the footprint name for KiCad stock footprints
function applyDest(c) {
  const wasLocked = c.footprintLocked;
  const d = computeDest(c);
  c.dest = d;
  c.names = c.names || {};
  if (d.footprint && d.footprint.source === 'kicad-default' && d.footprint.fpName) {
    c.names.footprint = d.footprint.fpName; c.footprintLocked = true;       // stock footprint -> fixed name
  } else {
    c.footprintLocked = false;
    if (wasLocked) c.names.footprint = c.mpn;                               // came off a stock lock -> default to MPN
  }
  return c;
}

// ---- create missing KiCad libraries on the user's machine (via adom-desktop)
const shq = s => "'" + String(s).replace(/'/g, "'\\''") + "'";
function regSnippet(table, name, uri) { // append a lib entry to a KiCad lib-table (idempotent, safe)
  const entry = `  (lib (name "${name}")(type "KiCad")(uri "${uri}")(options "")(descr ""))`;
  return `T="${table}"; [ -f "$T" ] && ! grep -qF '(name "${name}")' "$T" && { tmp=$(mktemp); head -n -1 "$T" > "$tmp"; printf '%s\\n)\\n' '${entry}' >> "$tmp"; mv "$tmp" "$T"; echo 'REG=${name}'; }`;
}
async function createMissingLibraries(components, opts = {}) {
  const symLibs = new Map(), fpLibs = new Map(), dirs = new Set();
  for (const c of components) {
    const d = c.dest || {};
    if (d.symbol && d.symbol.exists === false) symLibs.set(d.symbol.path, baseNoExt(d.symbol.path));
    if (d.footprint && d.footprint.exists === false) { fpLibs.set(d.footprint.path, baseNoExt(d.footprint.path)); dirs.add(d.footprint.path); }
    if (d.model3d && d.model3d.exists === false) dirs.add(d.model3d.path);
  }
  if (!symLibs.size && !fpLibs.size && !dirs.size) return { created: [] };
  const sh = ['CFG=$(ls -d "$HOME/.config/kicad"/*/ 2>/dev/null | head -1)'];
  for (const dir of dirs) sh.push(`mkdir -p ${shq(dir)} && echo "DIR=${dir}"`);
  for (const [pth, name] of symLibs) {
    sh.push(`[ -f ${shq(pth)} ] || { printf '(kicad_symbol_lib (version 20211014) (generator chip-fetcher-lite)\\n)\\n' > ${shq(pth)}; echo "SYM=${pth}"; }`);
    if (!opts.skipRegister) sh.push(regSnippet('${CFG}sym-lib-table', name, pth));   // temp dumps don't touch lib tables
  }
  if (!opts.skipRegister) for (const [pth, name] of fpLibs) sh.push(regSnippet('${CFG}fp-lib-table', name, pth));
  const out = await runDesktopShell(sh.join('\n'), 90);
  return { created: out.split('\n').map(l => l.trim()).filter(l => /^(DIR|SYM|REG)=/.test(l)), raw: out };
}

// ---------------------------------------------------------------- file the actual CAD into KiCad
function adomDesktopJSON(verb, argsObj, timeout = 180000) {
  return new Promise((resolve) => {
    execFile('adom-desktop', [verb, JSON.stringify(argsObj)], { timeout, maxBuffer: 64 << 20, env: process.env }, (err, stdout) => {
      let j = null; try { j = JSON.parse(stdout || '{}'); } catch {}
      resolve({ err, j, stdout: stdout || '' });
    });
  });
}
async function wikiBlob(slug, filename) {
  try {
    const r = await fetch(`${WIKI}/blob/component/${slug}/${encodeURIComponent(filename)}`, { signal: AbortSignal.timeout(45000) });
    if (!r.ok) return null;
    return Buffer.from(await r.arrayBuffer());
  } catch { return null; }
}
const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// get a component's symbol/footprint/3D source bytes (uploaded file wins, else wiki download)
async function getFileContent(c, kind) {
  const up = (c.uploads || []).find(u => u.kind === kind || (kind !== 'model3d' && u.kind === 'library'));
  if (up) { try { return { buf: fs.readFileSync(up.path), srcName: up.name }; } catch {} }
  const slug = c.wiki && c.wiki.slug; if (!slug) return null;
  const exts = kind === 'symbol' ? ['.kicad_sym'] : kind === 'footprint' ? ['.kicad_mod'] : ['.step', '.stp', '.wrl', '.glb'];
  for (const e of exts) { // honour ext priority (e.g. STEP before GLB for KiCad)
    const fname = (c._wiki && c._wiki.files || []).find(f => f.toLowerCase().endsWith(e));
    if (fname) { const buf = await wikiBlob(slug, fname); if (buf) return { buf, srcName: fname }; }
  }
  return null;
}
// does this component have ANY CAD to file (uploads or wiki files)? stubs (README only) don't.
function hasAnyCad(c) {
  if ((c.uploads || []).length) return true;
  const files = (c._wiki && c._wiki.files) || [];
  return files.some(f => /\.(kicad_sym|kicad_mod|step|stp|wrl|glb|lbr)$/i.test(f));
}
// send_files ignores `dest` on Linux (lands in ~/Downloads), so land then move into place
// Defensive repair: some wiki/uploaded .kicad_mod files have pad layers written comma-separated and
// unquoted (`(layers F.Cu,F.Paste,F.Mask)`), which KiCad reads as an SMD *aperture* (no copper).
// Requote to valid KiCad syntax on copy so footprints always land as proper SMD pads.
function normalizeFpLayers(buf) {
  try {
    const txt = buf.toString('utf8');
    if (!/\(layers [^"()]*,/.test(txt)) return buf;   // only touch the broken comma form
    const fixed = txt.replace(/\(layers ([^"()]+)\)/g, (m, g) =>
      '(layers ' + g.split(/[,\s]+/).filter(Boolean).map(t => `"${t.replace(/"/g, '')}"`).join(' ') + ')');
    return Buffer.from(fixed, 'utf8');
  } catch { return buf; }
}
async function sendFiles(filePaths, destDir) {
  const r = await adomDesktopJSON('send_files', { filePaths });
  const landed = (r.j && r.j.destinationPaths) || [];
  if (!landed.length) return { j: { success: false, error: (r.j && r.j.error) || 'send_files returned no paths' } };
  const mvs = landed.map(p => `mv -f ${shq(p)} ${shq(destDir)}/`).join('; ');
  const out = await runDesktopShell(`mkdir -p ${shq(destDir)}; ${mvs}; echo MOVED`, 60);
  return { j: { success: /MOVED/.test(out), landed, destDir } };
}

// --- KiCad .kicad_sym s-expression helpers (extract one symbol, rename, set props, merge)
function extractSymbolBlock(libText) {
  const i = libText.indexOf('(symbol "'); if (i < 0) return null;
  let depth = 0, j = i;
  for (; j < libText.length; j++) { const ch = libText[j]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { j++; break; } } }
  return libText.slice(i, j);
}
function symbolNameOf(block) { const m = block.match(/\(symbol\s+"([^"]+)"/); return m ? m[1] : null; }
function renameSymbol(block, oldN, newN) {
  return block
    .replace(new RegExp('\\(symbol\\s+"' + escapeRe(oldN) + '"'), `(symbol "${newN}"`)
    .replace(new RegExp('\\(symbol\\s+"' + escapeRe(oldN) + '_', 'g'), `(symbol "${newN}_`);
}
function setSymProp(block, name, value) {
  const v = String(value).replace(/"/g, '\\"');
  const re = new RegExp('\\(property\\s+"' + name + '"\\s+"(?:[^"\\\\]|\\\\.)*"');
  if (re.test(block)) return block.replace(re, `(property "${name}" "${v}"`);
  return block.replace(/(\(symbol\s+"[^"]+"\s*)/, `$1\n    (property "${name}" "${v}" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))`);
}
// keep only standard symbol properties; the specs go in Description, not separate fields
function stripExtraProps(block) {
  const keep = new Set(['Reference', 'Value', 'Footprint', 'Datasheet', 'Description']);
  let out = '', i = 0;
  while (i < block.length) {
    if (block.startsWith('(property "', i)) {
      const nm = (block.slice(i).match(/^\(property\s+"([^"]+)"/) || [])[1] || '';
      let depth = 0, j = i;
      for (; j < block.length; j++) { const ch = block[j]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { j++; break; } } }
      if (keep.has(nm)) out += block.slice(i, j);
      else { while (j < block.length && /[ \t\r\n]/.test(block[j])) j++; } // drop block + trailing ws
      i = j;
    } else { out += block[i]; i++; }
  }
  return out;
}
// insert the symbol; if one with this name exists, REPLACE it (so re-sending updates it)
function mergeSymbol(destText, block, newN) {
  const startRe = new RegExp('\\(symbol\\s+"' + escapeRe(newN) + '"');
  const m = startRe.exec(destText);
  if (m) {
    let depth = 0, j = m.index;
    for (; j < destText.length; j++) { const ch = destText[j]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { j++; break; } } }
    destText = destText.slice(0, m.index) + destText.slice(j);
  }
  const last = destText.lastIndexOf(')');
  if (last < 0) return { text: destText, skipped: false, error: 'bad lib' };
  const body = '  ' + block.replace(/\n/g, '\n  ') + '\n';
  return { text: destText.slice(0, last) + body + destText.slice(last), skipped: false };
}

async function fileFootprint(c, tmpdir) {
  const d = c.dest && c.dest.footprint; if (!d || !d.path) return 'no dest';
  let buf, fname;
  if (d.source === 'kicad-default') {
    // normal mode: the stock footprint is already in KiCad, nothing to copy.
    // temp mode (d.stockPath set): copy the stock .kicad_mod from the desktop into the temp .pretty.
    if (!d.stockPath || !d.fpName) return 'stock (skipped)';
    const r = await adomDesktopJSON('read_file', { path: `${d.stockPath}/${d.fpName}.kicad_mod`, encoding: 'base64' });
    if (!(r.j && r.j.success && r.j.content)) return 'stock (skipped)';
    buf = Buffer.from(r.j.content, 'base64'); fname = `${d.fpName}.kicad_mod`;
  } else {
    const got = await getFileContent(c, 'footprint'); if (!got) return 'no source file';
    buf = normalizeFpLayers(got.buf); fname = ((c.names && c.names.footprint) || c.mpn) + '.kicad_mod';
  }
  const tmp = path.join(tmpdir, fname); fs.writeFileSync(tmp, buf);
  const r = await sendFiles([tmp], d.path);
  return (r.j && r.j.success === false) ? ('send failed: ' + (r.j.error || '')) : 'ok';
}
async function file3D(c, tmpdir) {
  const d = c.dest && c.dest.model3d; if (!d || !d.path) return 'no dest';
  const got = await getFileContent(c, 'model3d'); if (!got) return 'no source file';
  const ext = (got.srcName.match(/\.[a-z0-9]+$/i) || ['.step'])[0];
  const name = ((c.names && c.names.symbol) || c.mpn) + ext;
  const tmp = path.join(tmpdir, name); fs.writeFileSync(tmp, got.buf);
  const r = await sendFiles([tmp], d.path);
  return (r.j && r.j.success === false) ? ('send failed: ' + (r.j.error || '')) : 'ok';
}
// redirect a component's destinations under a temp root (mirrors Symbols/Footprints/3D), for a
// dry-run export that doesn't touch the user's real libraries or lib tables.
function tempDest(c, tempRoot) {
  const d = c.dest || {}; const base = p => (p || '').split('/').pop();
  const t = (kd, sub) => d[kd] && d[kd].path ? { ...d[kd], path: `${tempRoot}/${sub}/${base(d[kd].path)}`, exists: false } : (d[kd] || null);
  const out = { symbol: t('symbol', 'Symbols'), footprint: t('footprint', 'Footprints'), model3d: t('model3d', '3D') };
  if (out.footprint && d.footprint && d.footprint.source === 'kicad-default') out.footprint.stockPath = d.footprint.path; // copy stock fp from here
  return out;
}
// build the renamed/stripped/propped symbol block for one component.
// opts.nickPrefix is prepended to the footprint-library nickname in the Footprint property so it
// matches how the footprint lib is registered (e.g. "TEMP_" for a temp export).
async function buildSymbolBlock(c, opts = {}) {
  const got = await getFileContent(c, 'symbol'); if (!got) return { err: 'no source file' };
  const block0 = extractSymbolBlock(got.buf.toString('utf8')); if (!block0) return { err: 'no symbol block' };
  const oldN = symbolNameOf(block0) || c.mpn;
  const newN = (c.names && c.names.symbol) || c.mpn;
  let block = renameSymbol(block0, oldN, newN);
  block = stripExtraProps(block);
  if (c.description) block = setSymProp(block, 'Description', c.description);
  const fpd = c.dest && c.dest.footprint;
  if (fpd) {
    const nick = (opts.nickPrefix || '') + baseNoExt(fpd.name);
    const fpName = (fpd.source === 'kicad-default' && fpd.fpName) ? fpd.fpName : ((c.names && c.names.footprint) || c.mpn);
    block = setSymProp(block, 'Footprint', `${nick}:${fpName}`);
  }
  if (c.datasheetUrl) block = setSymProp(block, 'Datasheet', c.datasheetUrl);
  if (c.jlcPart) block = setSymProp(block, 'LCSC', c.jlcPart);
  return { block, newN };
}
// merge ALL symbols destined for the same .kicad_sym in ONE read→merge→write (avoids the
// per-component read-modify-write race that left each lib with only the last symbol)
async function fileSymbolsBatched(components, tmpdir, opts = {}) {
  const byLib = new Map();
  for (const c of components) {
    const d = c.dest && c.dest.symbol; if (!d || !d.path || !hasAnyCad(c)) continue;
    if (!byLib.has(d.path)) byLib.set(d.path, { name: d.name, comps: [] });
    byLib.get(d.path).comps.push(c);
  }
  const countSyms = t => (t.match(/\(property "Reference"/g) || []).length;
  for (const [pth, grp] of byLib) {
    const nick = baseNoExt(grp.name);
    DB.send.current = `symbols → ${nick}`; save();
    // Read the existing library. SAFETY: only fall back to an empty template when the file is
    // genuinely ABSENT. If the read fails but the file EXISTS, refuse to write — never clobber a
    // user library we couldn't read. (This is the bug that once truncated real JLC libs to 1 symbol.)
    const r = await adomDesktopJSON('read_file', { path: pth, encoding: 'utf8' });
    let text;
    if (r.j && r.j.success && typeof r.j.content === 'string') {
      text = r.j.content;
    } else {
      const ex = await runDesktopShell(`[ -f ${shq(pth)} ] && echo EXISTS || echo MISSING`, 20);
      if (/EXISTS/.test(ex)) { DB.send.errors.push(`symbols ${nick}: SKIPPED — couldn't read existing library; refusing to overwrite (nothing lost)`); continue; }
      text = '(kicad_symbol_lib (version 20211014) (generator chip-fetcher-lite)\n)\n';
    }
    const before = countSyms(text);
    let added = 0;
    for (const c of grp.comps) {
      try {
        const b = await buildSymbolBlock(c, opts);
        if (b.err) { DB.send.errors.push(`${c.mpn} symbol: ${b.err}`); continue; }
        const m = mergeSymbol(text, b.block, b.newN); text = m.text; if (!m.skipped) added++;
      } catch (e) { DB.send.errors.push(`${c.mpn} symbol: ${e.message || e}`); }
    }
    // SAFETY: never write a library that has FEWER symbols than it already had.
    const after = countSyms(text);
    if (after < before) { DB.send.errors.push(`symbols ${nick}: SKIPPED — write would drop ${before}→${after} symbols; refusing (no data lost)`); continue; }
    await runDesktopShell(`cp ${shq(pth)} ${shq(pth + '.bak')} 2>/dev/null || true`, 20);
    const tmp = path.join(tmpdir, nick + '.kicad_sym'); fs.writeFileSync(tmp, text);
    const sr = await sendFiles([tmp], dirOf(pth));
    if (sr.j && sr.j.success === false) DB.send.errors.push(`symbols ${nick}: send failed`);
    DB.send.current = `symbols → ${nick} (${added})`; save();
  }
}
async function fileToLibrary(components, opts = {}) {
  const temp = !!opts.tempRoot;
  DB.send = { status: 'running', total: components.length, done: 0, current: temp ? `exporting to ${opts.tempRoot}…` : 'creating libraries…', errors: [], startedAt: new Date().toISOString(), tempRoot: opts.tempRoot || null };
  save();
  const orig = new Map();
  if (temp) for (const c of components) { orig.set(c.id, c.dest); c.dest = tempDest(c, opts.tempRoot); }
  try { await createMissingLibraries(components, { skipRegister: temp }); } catch (e) { DB.send.errors.push('create libs: ' + (e.message || e)); }
  const tmpdir = path.join(DATA_DIR, 'send'); fs.mkdirSync(tmpdir, { recursive: true });
  // 1) footprints + 3D per component (independent files)
  for (const c of components) {
    if (!hasAnyCad(c)) { DB.send.errors.push(`${c.mpn}: skipped — no CAD on the wiki`); DB.send.done++; save(); continue; }
    for (const [kind, fn] of [['footprint', fileFootprint], ['3D model', file3D]]) {
      DB.send.current = `${c.mpn}: ${kind}`; save();
      try { const r = await fn(c, tmpdir); if (r && !/^(ok|stock|already)/.test(r)) DB.send.errors.push(`${c.mpn} ${kind}: ${r}`); }
      catch (e) { DB.send.errors.push(`${c.mpn} ${kind}: ${e.message || e}`); }
    }
    if (!temp) for (const kd of ['footprint', 'model3d']) if (c.dest && c.dest[kd]) c.dest[kd].exists = true;
    DB.send.done++; save();
  }
  // 2) symbols batched per library (one read→merge-all→write).
  //    In temp mode the footprint libs are registered as TEMP_<nick>, so the symbols' Footprint
  //    property must use that prefix to resolve.
  await fileSymbolsBatched(components, tmpdir, { nickPrefix: temp ? 'TEMP_' : '' });
  if (!temp) for (const c of components) if (c.dest && c.dest.symbol) c.dest.symbol.exists = true;
  if (temp) for (const c of components) c.dest = orig.get(c.id);   // restore real dests; temp send is non-destructive
  DB.send.status = 'done';
  DB.send.current = (temp ? `Exported ${DB.send.done} to temp` : `Filed ${DB.send.done} component(s)`) + (DB.send.errors.length ? ` (${DB.send.errors.length} issue(s))` : '');
  DB.send.finishedAt = new Date().toISOString(); save();
}

// ---------------------------------------------------------------- http helpers
function sendJSON(res, code, obj) {
  const b = Buffer.from(JSON.stringify(obj));
  res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': b.length });
  res.end(b);
}
function readBody(req, limit = 64 << 20) {
  return new Promise((resolve, reject) => {
    const chunks = []; let n = 0;
    req.on('data', d => { n += d.length; if (n > limit) { reject(new Error('too large')); req.destroy(); } else chunks.push(d); });
    req.on('end', () => resolve(Buffer.concat(chunks)));
    req.on('error', reject);
  });
}
async function readJSON(req) { const b = await readBody(req); return b.length ? JSON.parse(b.toString('utf8')) : {}; }

const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png' };
function serveStatic(res, urlPath) {
  let rel = urlPath === '/' ? '/index.html' : urlPath;
  const fp = path.join(PUBLIC_DIR, path.normalize(rel).replace(/^(\.\.[/\\])+/, ''));
  if (!fp.startsWith(PUBLIC_DIR) || !fs.existsSync(fp) || !fs.statSync(fp).isFile()) { res.writeHead(404); return res.end('not found'); }
  const body = fs.readFileSync(fp);
  res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });
  res.end(body);
}

// ---------------------------------------------------------------- routes
const server = http.createServer(async (req, res) => {
  const u = new URL(req.url, 'http://localhost');
  const p = u.pathname;
  try {
    if (!p.startsWith('/api/')) return serveStatic(res, p);

    // ---- state
    if (p === '/api/state' && req.method === 'GET') return sendJSON(res, 200, DB);

    // ---- add component
    if (p === '/api/components' && req.method === 'POST') {
      const { name } = await readJSON(req);
      if (!name || !name.trim()) return sendJSON(res, 400, { error: 'name required' });
      // don't add duplicates — match an existing component by normalized name or MPN
      const want = norm(name);
      const dup = DB.components.find(x => norm(x.name) === want || norm(x.mpn) === want);
      if (dup) return sendJSON(res, 200, { ...dup, _duplicate: true });
      const c = {
        id: uid(), name: name.trim(), mpn: name.trim(), slug: norm(name),
        type: 'Checking…', passive: false, selected: false,
        wiki: { status: 'checking', slug: null, variants: [], checkedAt: null },
        eda: freshEda(),
        _wiki: { files: [], has3d: false },
        wikiFiles: { '3d': false },
        uploads: [],
        dest: { symbol: null, footprint: null, model3d: null },
        names: { symbol: name.trim(), footprint: name.trim() }, // editable library names; default to the MPN
        manufacturer: null, subcategory: null, jlc: false, jlcPart: null, description: '',
        addedAt: new Date().toISOString(),
      };
      // if libraries are already probed, file new components into them by default (refined after wiki check)
      if (DB.probe && DB.probe.libraries && Object.keys(DB.probe.libraries).length) applyDest(c);
      DB.components.unshift(c); save();
      // kick off check in the background
      enqueueCheck(c.id);
      return sendJSON(res, 200, c);
    }

    // ---- per-component routes
    let m;
    if ((m = p.match(/^\/api\/components\/([0-9a-f]+)(\/[a-z-]+)?$/))) {
      const c = find(m[1]);
      if (!c) return sendJSON(res, 404, { error: 'not found' });
      const action = m[2];

      if (!action && req.method === 'DELETE') {
        DB.components = DB.components.filter(x => x.id !== c.id);
        fs.rmSync(path.join(STAGING_DIR, c.id), { recursive: true, force: true });
        save(); return sendJSON(res, 200, { ok: true });
      }
      if (action === '/check' && req.method === 'POST') {
        c.wiki.status = 'checking'; save();
        await runCheck(c); save(); return sendJSON(res, 200, c);
      }
      if (action === '/select' && req.method === 'POST') {
        const { on } = await readJSON(req); c.selected = !!on; save(); return sendJSON(res, 200, c);
      }
      if (action === '/jlc' && req.method === 'POST') {
        const { jlcPart } = await readJSON(req);
        c.jlcPart = (jlcPart || '').trim() || null;
        c.jlc = !!c.jlcPart;
        save(); return sendJSON(res, 200, c);
      }
      if (action === '/names' && req.method === 'POST') {
        const { symbol, footprint } = await readJSON(req);
        if (!c.names) c.names = {};
        if (symbol !== undefined) c.names.symbol = String(symbol).trim();
        if (footprint !== undefined && !c.footprintLocked) c.names.footprint = String(footprint).trim();
        save(); return sendJSON(res, 200, c);
      }
      if (action === '/upload' && req.method === 'POST') {
        const filename = (u.searchParams.get('filename') || 'file.bin').replace(/[/\\]/g, '_');
        const replace = u.searchParams.get('replace') === '1';
        const hit = classifyFile(filename);
        if (!hit) return sendJSON(res, 400, { error: `unrecognized file type: ${filename}` });
        const existing = c.uploads.find(x => x.eda === hit.eda && x.kind === hit.kind);
        if (existing && !replace) return sendJSON(res, 409, { error: 'exists', existing });
        const body = await readBody(req);
        const dir = path.join(STAGING_DIR, c.id); fs.mkdirSync(dir, { recursive: true });
        const fp = path.join(dir, filename); fs.writeFileSync(fp, body);
        if (existing) Object.assign(existing, { name: filename, path: fp, size: body.length, uploadedAt: new Date().toISOString() });
        else c.uploads.push({ eda: hit.eda, kind: hit.kind, name: filename, path: fp, size: body.length, uploadedAt: new Date().toISOString() });
        recomputeEda(c);
        save(); return sendJSON(res, 200, c);
      }
    }

    // ---- selection: all
    if (p === '/api/select-all' && req.method === 'POST') {
      const { on } = await readJSON(req);
      for (const c of DB.components) c.selected = !!on;
      save(); return sendJSON(res, 200, { ok: true });
    }

    // ---- probe: UI requests a probe -> scan the user's machine via adom-desktop
    if (p === '/api/probe-request' && req.method === 'POST') {
      const { edas } = await readJSON(req);
      const sel = (edas && edas.length) ? edas : ['kicad', 'altium', 'fusion'];
      DB.probe = { ...(DB.probe || {}), edas: sel, status: 'requested' }; save();
      // run asynchronously; the UI polls /api/state and shows "probing…" until done
      probeUserLibraries(sel).then(({ libraries }) => {
        DB.probe = { edas: sel, libraries, probedAt: new Date().toISOString(), status: 'done' };
        for (const c of DB.components) applyDest(c);
        save();
      }).catch(e => { DB.probe = { ...DB.probe, status: 'error', error: String(e && e.message || e) }; save(); });
      return sendJSON(res, 200, { status: 'requested', edas: sel });
    }
    // ---- probe: agent posts results
    if (p === '/api/probe' && req.method === 'POST') {
      const { edas, libraries, defaults, destinations } = await readJSON(req);
      DB.probe = {
        edas: edas || DB.probe.edas, libraries: libraries || {},
        defaults: defaults || (DB.probe && DB.probe.defaults) || null, // default symbol/footprint/3d destination
        probedAt: new Date().toISOString(), status: 'done',
      };
      // apply the default destination to every component that doesn't have a specific one
      if (defaults) for (const c of DB.components) {
        if (!c.dest || (!c.dest.symbol && !c.dest.footprint && !c.dest.model3d)) c.dest = JSON.parse(JSON.stringify(defaults));
      }
      // per-component overrides: { <componentId>: { symbol:{name,path}, footprint:{name,path}, model3d:{name,path} } }
      if (destinations) for (const [id, d] of Object.entries(destinations)) { const c = find(id); if (c) c.dest = d; }
      save(); return sendJSON(res, 200, DB.probe);
    }

    // ---- send to library: create any missing libraries on the user's machine, then file in.
    // {temp:true} (or an explicit tempRoot) exports to a temp folder instead of the real libraries.
    if (p === '/api/send-to-library' && req.method === 'POST') {
      const { ids, edas, temp, tempRoot } = await readJSON(req);
      const sel = (ids && ids.length) ? ids : DB.components.filter(c => c.selected).map(c => c.id);
      if (!sel.length) return sendJSON(res, 400, { error: 'no components selected' });
      const comps = sel.map(find).filter(Boolean);
      if (DB.send && DB.send.status === 'running') return sendJSON(res, 409, { error: 'a send is already in progress' });
      let root = tempRoot || null;
      if (!root && temp) { // derive a temp root next to the user's KiCad dir
        const sd = DB.probe && DB.probe.libraries && DB.probe.libraries.kicad && DB.probe.libraries.kicad.symbolsDir;
        root = sd ? dirOf(dirOf(sd)) + '/chip-fetcher-lite-temp' : null;
        if (!root) return sendJSON(res, 400, { error: 'probe libraries first so a temp root can be derived, or pass tempRoot' });
      }
      fileToLibrary(comps, root ? { tempRoot: root } : {}).catch(e => { DB.send.status = 'error'; DB.send.current = String(e && e.message || e); save(); });
      return sendJSON(res, 200, { status: 'running', total: comps.length, tempRoot: root });
    }

    // ---- agent job queue
    if (p === '/api/jobs' && req.method === 'GET')
      return sendJSON(res, 200, { jobs: DB.jobs.filter(j => j.status === 'pending') });
    if ((m = p.match(/^\/api\/jobs\/([0-9a-f]+)\/complete$/)) && req.method === 'POST') {
      const job = DB.jobs.find(j => j.id === m[1]);
      if (!job) return sendJSON(res, 404, { error: 'not found' });
      const { result } = await readJSON(req);
      job.status = 'done'; job.result = result || null; job.completedAt = new Date().toISOString();
      save(); return sendJSON(res, 200, job);
    }

    return sendJSON(res, 404, { error: 'no route' });
  } catch (e) {
    return sendJSON(res, 500, { error: String(e && e.message || e) });
  }
});

server.listen(PORT, () => {
  console.log(`chip-fetcher-lite listening on http://localhost:${PORT}`);
  console.log(`  data: ${DATA_FILE}`);
});