// sbom/wizard_components.jsx — "Create component" workflow.
// Five steps: Identity → SBOM → Refine (advanced editor) → Match → Review.
// Renders in two layout directions selected by the `layout` prop:
//   • "rail"    — left vertical step rail, focused single-panel content
//   • "stepper" — top horizontal stepper + live build-up summary sidebar
// Both share the same step bodies; only the surrounding chrome differs.

const WIZ_STEPS = [
  { id: "identity", label: "Identity",  icon: "package", sub: "Name & type" },
  { id: "sbom",     label: "SBOM/HBOM", icon: "doc",     sub: "Upload or generate"},
  { id: "refine",   label: "Refine",    icon: "pencil",  sub: "Edit inventories", optional: true },
  { id: "match",    label: "Match",     icon: "link",    sub: "Supplier" },
  { id: "review",   label: "Review",    icon: "sparkle", sub: "Matched CVEs" },
];

function catOf(id) { return window.VC.COMPONENT_CATEGORIES.find(c => c.id === id); }

/* ====================================================== step: Identity */
function IdentityStep({ name, setName, category, setCategory, ecu, setEcu }) {
  // Collapsed until wanted: most components are named and typed, nothing more. Open on
  // arrival when something was filled in before; after that the user owns the toggle
  // (a value React kept re-deciding would fold the section shut on clearing a field).
  const [ecuOpen] = useState(() => ECU_FIELDS.some(f => (ecu[f.id] || "").trim()));
  return (
    <div className="wizform">
      <div className="wizform-head">
        <h2>Define the component</h2>
        <p>Give the asset a name your team will recognise, then classify what kind of component it is.</p>
      </div>
      <div className="field">
        <label className="field-lab">Component name</label>
        <div className="field-wrap">
          <Icon name="package" size={16} className="field-ic" />
          <input value={name} onChange={e => setName(e.target.value)} autoFocus
            placeholder="e.g. Telematics Control Unit — Connectivity FW" />
        </div>
      </div>
      <div className="field">
        <label className="field-lab">Category / type</label>
        <div className="catgrid">
          {window.VC.COMPONENT_CATEGORIES.map(c => (
            <button key={c.id} type="button" className={"catcard" + (category === c.id ? " is-on" : "")}
              onClick={() => setCategory(c.id)}>
              <span className="catcard-ic"><Icon name={c.icon} size={18} /></span>
              <span className="catcard-lab">{c.label}</span>
              <span className="catcard-hint">{c.hint}</span>
            </button>
          ))}
        </div>
      </div>
      {/* <details className="ecu-details" open={ecuOpen}>
        <summary>ECU details <span className="field-opt">optional</span></summary>
        <p className="ecu-details-sub">Which unit this software is: kept with the build's SBOM, so a finding can be traced to the part number it ships in.</p>
        <div className="ecu-grid">
          {ECU_FIELDS.map(f => (
            <div className="field" key={f.id}>
              <label className="field-lab" htmlFor={"ecu-" + f.id}>{f.label}</label>
              <div className="field-wrap">
                <input id={"ecu-" + f.id} value={ecu[f.id] || ""} maxLength={128} placeholder={f.placeholder}
                  onChange={e => setEcu({ ...ecu, [f.id]: e.target.value })} />
              </div>
            </div>
          ))}
        </div>
      </details> */}
    </div>
  );
}

/* ===================================================== reading SBOM files */
// Every SBOM is read on the server (POST /api/sbom/parse, SbomParser in the API), by the
// same reader a generated SBOM goes through, so there is one parser and not two that
// can drift apart. It replaced the parser this file used to carry: it gives that
// parser's answers on everything matching reads (checked over the repo's sample SBOMs,
// see backend/README.md) and keeps what it dropped — licences, hashes, CPE, the
// dependency graph and the linker-map verdict.
//
// The browser still reads the text itself: that text is what "View source" shows and
// what the build keeps as its original document.
function sbomFileText(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(String(reader.result || ""));
    reader.onerror = () => reject(Object.assign(new Error(`${file.name} could not be read on this computer. Check that it is still there.`),
      { title: "File not readable" }));
    reader.readAsText(file);
  });
}

// A picked SBOM file -> { meta, libs, relationships, raw, filename }. Throws an Error
// whose message is fit to show: the server's own reason when it could not read the file.
async function readSbomFile(file, signal) {
  const max = window.VCApi.SBOM_MAX_BYTES;
  if (file.size > max)
    throw Object.assign(new Error(`${file.name} is ${sbomgenSize(file.size)}. The limit for an SBOM is ${sbomgenSize(max)}.`), { title: "SBOM too large" });
  const [raw, parsed] = await Promise.all([sbomFileText(file), window.VCApi.parseSbom(file, signal)]);
  if (!parsed || !Array.isArray(parsed.libs) || !parsed.libs.length)
    throw Object.assign(new Error(`${file.name} lists no components.`), { title: "No components" });
  return { meta: parsed.meta, libs: parsed.libs, relationships: parsed.relationships || [], raw, filename: file.name,
    repeatsMerged: parsed.repeatsMerged || 0 };
}

// "View source": the document laid out to be read. Syft and cdxgen write JSON on a single
// line (as do many CycloneDX XML writers), which is unreadable. Only the VIEW is
// re-indented; the text kept with the build and offered for download stays byte for byte
// as it was. Numbers pass through JSON.parse here, so a value JavaScript cannot hold
// exactly (a 17-digit integer) would show rounded, in the view and nowhere else.
// Past SOURCE_VIEW_MAX characters the rest is left out: the browser crawls on a <pre>
// of many megabytes, and the download has all of it.
const SOURCE_VIEW_MAX = 1000000;
function sbomSourceView(text) {
  const t = String(text || "").replace(/^﻿/, "");
  const head = t.trimStart();
  let shown = t;
  if (head.startsWith("{") || head.startsWith("[")) {
    try { shown = JSON.stringify(JSON.parse(t), null, 2); } catch (_) { /* not JSON after all: as it is */ }
  } else if (head.startsWith("<")) {
    shown = sbomPrettyXml(t) || t;
  }
  return shown.length > SOURCE_VIEW_MAX
    ? { text: shown.slice(0, SOURCE_VIEW_MAX), cut: shown.length }
    : { text: shown, cut: 0 };
}

// XML, one element per line, two spaces per level; an element holding only text keeps it
// on its own line. Null when the text does not parse (then it is shown as it is).
function sbomPrettyXml(text) {
  const doc = new DOMParser().parseFromString(text, "application/xml");
  if (doc.getElementsByTagName("parsererror").length) return null;
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  const lines = [];
  const decl = /^\s*<\?xml[^?]*\?>/.exec(text);
  if (decl) lines.push(decl[0].trim());
  const walk = (node, depth) => {
    const pad = "  ".repeat(depth);
    if (node.nodeType === 3) { const v = node.nodeValue.trim(); if (v) lines.push(pad + esc(v)); return; }
    if (node.nodeType === 4) { lines.push(pad + "<![CDATA[" + node.nodeValue + "]]>"); return; }
    if (node.nodeType === 7) { lines.push(pad + "<?" + node.target + " " + node.data + "?>"); return; }
    if (node.nodeType === 8) { lines.push(pad + "<!--" + node.nodeValue + "-->"); return; }
    if (node.nodeType !== 1) return;
    const attrs = Array.from(node.attributes)
      .map(a => " " + a.name + "=\"" + esc(a.value).replace(/"/g, "&quot;") + "\"").join("");
    const kids = Array.from(node.childNodes).filter(k => !(k.nodeType === 3 && !k.nodeValue.trim()));
    if (!kids.length) { lines.push(pad + "<" + node.nodeName + attrs + "/>"); return; }
    if (kids.length === 1 && kids[0].nodeType === 3) {
      lines.push(pad + "<" + node.nodeName + attrs + ">" + esc(kids[0].nodeValue.trim()) + "</" + node.nodeName + ">");
      return;
    }
    lines.push(pad + "<" + node.nodeName + attrs + ">");
    kids.forEach(k => walk(k, depth + 1));
    lines.push(pad + "</" + node.nodeName + ">");
  };
  Array.from(doc.childNodes).forEach(n => walk(n, 0));
  return lines.join("\n");
}

// What a build keeps of the SBOM it was created from: the document itself and what it
// says about itself, plus the ECU fields from the Identity step. `sbom` may be null (an
// inventory edited by hand): the ECU fields are kept all the same.
function sbomDocumentFor(sbom, ecu) {
  const m = (sbom && sbom.meta) || {};
  const md = m.metadata || {};
  const e = ecu || {};
  const val = (s) => (s && String(s).trim()) || null;
  return {
    fileName: (sbom && sbom.filename) || null,
    format: m.format || null,
    specVersion: m.specVersion || null,
    tools: Array.isArray(md.tools) ? md.tools : [],
    // "(no serial)" and "(no namespace)" are this screen's words for none.
    serialNumber: m.serialNumber && !/^\(no /.test(m.serialNumber) ? m.serialNumber : null,
    created: md.created || null,
    hwPartNumber: val(e.hwPartNumber),
    swPartNumber: val(e.swPartNumber),
    mcu: val(e.mcu),
    variantId: val(e.variantId),
    content: (sbom && sbom.raw) || null,
  };
}

// The edges between the libraries actually being saved, and the product itself: a row
// deleted in the editor takes its edges with it.
function sbomEdgesFor(sbom, libs) {
  const known = new Set((libs || []).map(l => l.bomRef).filter(Boolean));
  const product = sbom && sbom.meta && sbom.meta.metadata && sbom.meta.metadata.component;
  if (product && product.bomRef) known.add(product.bomRef);
  return ((sbom && sbom.relationships) || []).filter(r => known.has(r.from) && known.has(r.to));
}

// A component list to fill in, with every column the CSV reader understands beyond name
// and version. linked_in_binary says whether it is in the firmware image, as a linker map
// does; parent_component names the component_name of the part it sits inside. The
// example rows say EXAMPLE, so one left in is plain to see and matches nothing.
const SBOM_MANIFEST_TEMPLATE = [
  "component_name,version,type,supplier,license,purl,cpe,sha256,part_number,linked_in_binary,parent_component",
  "EXAMPLE ECU application,1.0.0,firmware,Your company,,,,,SW-0000-EXAMPLE,yes,",
  "EXAMPLE crypto library,2.28.3,library,Example supplier,Apache-2.0,,,,,yes,EXAMPLE ECU application",
  "EXAMPLE unused driver,4.1.0,library,Example supplier,,,,,,no,EXAMPLE ECU application",
].join("\r\n") + "\r\n";

// What the Identity step asks about the ECU itself. All optional; kept with the build.
const ECU_FIELDS = [
  { id: "hwPartNumber", label: "Hardware part number", placeholder: "e.g. HW-4711-02" },
  { id: "swPartNumber", label: "Software part number", placeholder: "e.g. SW-4711-02-031" },
  { id: "mcu", label: "Microcontroller", placeholder: "e.g. Infineon AURIX TC387" },
  { id: "variantId", label: "Variant", placeholder: "e.g. EU, 400 V" },
];
const EMPTY_ECU = { hwPartNumber: "", swPartNumber: "", mcu: "", variantId: "" };

/* ========================================================== step: SBOM */
// Rows shown in the inventory preview before "Show more", how many each click adds, and
// the largest list "Show all" renders in one go (a 50,000-package SBOM would stall the page).
const PREVIEW_ROWS = 7;
const PREVIEW_STEP = 50;
const PREVIEW_ALL_MAX = 2000;
// Libraries sent per match request. Small enough that the first results land quickly,
// large enough not to drown the server in round trips.
const MATCH_BATCH = 40;

function UploadStep({ sbom, libs, matches, matchLoading, matchError, matchedKeys, matchProgress, onParsed, onClear, onRetryMatch, componentName }) {
  const [parsing, setParsing] = useState(false);
  const [drag, setDrag] = useState(false);
  const [raw, setRaw] = useState(false);
  const [err, setErr] = useState("");
  // Where the SBOM comes from: a file the user already has, or one generated from
  // their source code (sbom/generate_sbom.jsx). Both land in onParsed() with the
  // same shape, so everything after this step is shared.
  const [source, setSource] = useState("file");      // file | code
  const inputRef = useRef(null);
  // Laid out only while it is open: re-indenting a large SBOM is real work.
  const sourceView = useMemo(() => (raw && sbom ? sbomSourceView(sbom.raw) : null), [raw, sbom]);

  // Per-row CVE counts come from the SERVER match set — the same numbers behind the
  // "CVE matches" stat above. window.VC.libVulnCount() correlates in the browser
  // against a different rule and disagreed with it: the header read 500 while this
  // list flagged one row with 2.
  //
  // Withheld entirely while the match is in flight or has failed. A green tick is a
  // claim ("no CVEs affect this library") and rendering it from a result we do not
  // have yet is the one wrong answer this screen must never give.
  //
  // useVulnCounts() is defined in sbom/sbom_editor.jsx, which the page loads first.
  // Both hooks sit above the early return below, so they run on every render.
  // Counts update after EVERY batch, not once at the end — matchedKeys says which
  // libraries have an answer so far, so a row can show its count the moment its own
  // batch lands while later rows are still pending.
  const liveCounts = useVulnCounts(matches);
  const answered = (l) => !!(matchedKeys && matchedKeys.has(window.VC.normLib(l && l.name)));
  // How many rows the preview shows; back to the first few whenever another SBOM comes in.
  const [shown, setShown] = useState(PREVIEW_ROWS);
  useEffect(() => { setShown(PREVIEW_ROWS); }, [sbom]);
  const previewRows = useMemo(() => {
    const rows = (libs || []).map((l, i) => ({ l, i }));
    if (!matchedKeys || !matchedKeys.size) return rows.slice(0, shown);  // nothing back yet
    const n = (l) => (liveCounts ? liveCounts.get(window.VC.normLib(l && l.name)) || 0 : 0);
    // Most-affected first, then alphabetical — so the seven rows on show are the seven
    // worth looking at, not whichever seven the scanner happened to list first.
    return rows.sort((a, b) => {
      const d = n(b.l) - n(a.l);
      return d || (a.l.name || "").localeCompare(b.l.name || "", undefined, { sensitivity: "base" });
    }).slice(0, shown);
  }, [libs, liveCounts, matchedKeys, shown]);

  // Leaving the step mid-read cancels the request; nobody is left to show the answer to.
  const readRef = useRef(null);
  useEffect(() => () => { if (readRef.current) readRef.current.abort(); }, []);

  async function handleFile(file) {
    if (!file) return;
    setErr(""); setParsing(true);
    const ctrl = new AbortController();
    readRef.current = ctrl;
    try {
      const parsed = await readSbomFile(file, ctrl.signal);
      if (ctrl.signal.aborted) return;
      setParsing(false);
      onParsed(parsed);
    } catch (e) {
      if (ctrl.signal.aborted || (e && e.aborted)) return;
      setParsing(false);
      if (e && e.status === 401 && window.__vcOn401) { window.__vcOn401(); return; }
      setErr(sbomgenError(e));
    } finally {
      if (readRef.current === ctrl) readRef.current = null;
    }
  }

  if (parsing) {
    return (
      <div className="wizform">
        <div className="drop is-parsing">
          <span className="btn-spin drop-spin" />
          <strong>Parsing SBOM document…</strong>
          <span className="drop-sub">Validating schema · extracting components · normalising PURLs</span>
        </div>
      </div>
    );
  }

  if (!sbom) {
    return (
      <div className="wizform">
        <div className="wizform-head">
          <h2>Add a software bill of materials</h2>
          <p>Upload an SBOM you already have, or generate one from the source code. Either way, vulnerabilitycore reads the component inventory and matches each library against the live CVE ledger.</p>
        </div>
        <div className="seg sbom-src-seg" role="tablist" aria-label="Where the SBOM comes from">
          <button type="button" role="tab" aria-selected={source === "file"}
            className={"seg-btn" + (source === "file" ? " is-on" : "")}
            onClick={() => { setSource("file"); setErr(""); }}><Icon name="doc" size={14} />Upload SBOM file</button>
          <button type="button" role="tab" aria-selected={source === "code"}
            className={"seg-btn" + (source === "code" ? " is-on" : "")}
            onClick={() => { setSource("code"); setErr(""); }}><Icon name="package" size={14} />Generate from source code</button>
        </div>
        {source === "code" ? (
          <SourceSbomGenerator componentName={componentName} onGenerated={onParsed} />
        ) : (
          <>
            <input ref={inputRef} type="file" accept={window.VCApi.SBOM_FILE_ACCEPT}
              style={{ display: "none" }} onChange={e => { handleFile(e.target.files && e.target.files[0]); e.target.value = ""; }} />
            <div className={"drop" + (drag ? " is-drag" : "")}
              onDragOver={e => { e.preventDefault(); setDrag(true); }}
              onDragLeave={() => setDrag(false)}
              onDrop={e => {
                e.preventDefault(); setDrag(false);
                const problem = sbomDropProblem(e.dataTransfer, SBOM_DROP_FILE);
                if (problem) setErr(problem);
                else handleFile(e.dataTransfer.files && e.dataTransfer.files[0]);
              }}
              onClick={() => inputRef.current && inputRef.current.click()} style={{ cursor: "pointer" }}>
              <span className="drop-ic"><Icon name="upload" size={26} /></span>
              <strong>Drag a CycloneDX, SPDX or CSV file here, or click to browse</strong>
              <span className="drop-sub">Accepts <b>.cdx.json</b>, <b>.cdx.xml</b> (CycloneDX 1.2 – 1.7) · <b>.spdx.json</b> (SPDX 2.2 – 2.3, 3.0) · or a <b>.csv</b> component list · up to {sbomgenSize(window.VCApi.SBOM_MAX_BYTES)}</span>
              <div className="drop-actions">
                <button className="btn btn-accent" onClick={e => { e.stopPropagation(); inputRef.current && inputRef.current.click(); }}><Icon name="upload" size={14} />Choose file</button>
              </div>
            </div>
            <p className="sbom-template">
              <Icon name="doc" size={13} />
              <span>No SBOM? List the components in a spreadsheet instead: name and version are enough, and the template has columns for licence, hashes, part numbers, what is linked into the binary and what sits inside what.</span>
              <button type="button" className="btn btn-sm btn-ghost"
                onClick={() => sbomDownload("component-manifest-template.csv", SBOM_MANIFEST_TEMPLATE)}>
                <Icon name="download" size={13} />CSV template</button>
            </p>
            <SbomgenErr err={err} style={{ marginTop: 12 }} />
          </>
        )}
      </div>
    );
  }

  const m = sbom.meta;
  matches = matches || [];
  return (
    <div className="wizform">
      <div className="wizform-head wizform-head-row">
        <div>
          <h2>{sbom.generated ? "SBOM generated" : "SBOM parsed"}</h2>
          <p>{sbom.generated
            ? <>{sbom.generated.toolName} {sbom.generated.tool === "vulnerabilitycore" ? "read" : "scanned"} <span className="mono">{sbom.generated.source}</span> into <span className="mono">{sbom.filename}</span>
                {sbom.generated.packageCount > 0 && (
                  <span title={(sbom.generated.manifests || []).map(f => f.path + " (" + f.components + ")").join("\n")}>
                    {" · "}<b>{sbom.generated.packageCount}</b> from lock files and manifests ({(sbom.generated.manifests || []).length} {(sbom.generated.manifests || []).length === 1 ? "file" : "files"})</span>)}
                {sbom.generated.sourceDetectedCount > 0 && <> · <b>{sbom.generated.sourceDetectedCount}</b> found by the C/C++ source check</>}
                {sbom.generated.binary && sbom.generated.binary.filesAnalyzed > 0 && (
                  <span title={sbomBinaryTitle(sbom.generated.binary)}>
                    {" · "}<b>{sbom.generated.binaryDetectedCount}</b> found in compiled code ({sbom.generated.binary.filesAnalyzed} {sbom.generated.binary.filesAnalyzed === 1 ? "file" : "files"} analysed
                    {(sbom.generated.binary.unidentified || []).length > 0 && <>, <b>{sbom.generated.binary.unidentified.length}</b> with nothing recognised</>})</span>)}
                {sbom.generated.scannerAddedCount > 0 && <> · <b>{sbom.generated.scannerAddedCount}</b> more from a deeper scan of the files</>}
                {sbom.generated.linkerMap && <> · linker map: {sbom.generated.linkerMap.matchesSources
                  ? <><b>{sbom.generated.linkerMap.linked}</b> linked, <b>{sbom.generated.linkerMap.notLinked}</b> not linked</>
                  : "does not match these sources"}</>}
                {sbom.generated.compileCommands && (
                  <span title={sbomCompileTitle(sbom.generated.compileCommands)}> · build: {sbom.generated.compileCommands.matchesSources
                    ? <><b>{sbom.generated.compileCommands.compiled}</b> compiled, <b>{sbom.generated.compileCommands.notCompiled}</b> not compiled</>
                    : "does not match these sources"}</span>)}
                {(sbom.generated.toolchains || []).length > 0 && (
                  <span title={sbomToolchainTitle(sbom.generated.toolchains)}> · built with {sbom.generated.toolchains.slice(0, 2).map(sbomToolchainLabel).join(", ")}
                    {sbom.generated.toolchains.length > 2 && <> and {sbom.generated.toolchains.length - 2} more</>}</span>)}
                {sbom.generated.licensedCount > 0 && <> · <b>{sbom.generated.licensedCount}</b> with a licence</>}
                {sbom.generated.inventory && (
                  <span title={"SHA-256 of every file; download the list from Download SBOM"
                    + (sbom.generated.inventory.stoppedEarly ? "\nThe time ran out before every file was hashed." : "")}>
                    {" · "}<b>{sbom.generated.inventory.files}</b> {sbom.generated.inventory.files === 1 ? "file" : "files"} inventoried</span>)}</>
            : sbom.filename}</p>
        </div>
        <div className="wizform-head-actions">
          {/* The server keeps nothing it generates, so this is the user's copy: as
              generated (CycloneDX), or written out as SPDX or CSV, and the scan's file inventory. */}
          {sbom.generated && <SbomDownloadMenu options={generatedSbomOptions(sbom.filename, sbom.raw, m.specVersion, sbom.generated.inventory)} />}
          <button className="btn btn-sm" onClick={onClear}><Icon name="refresh" size={14} />{sbom.generated ? "Start over" : "Replace file"}</button>
        </div>
      </div>

      <div className="sbomsum">
        <div className="sbomsum-stat"><b>{libs.length}</b><span>inventories</span></div>
        <div className="sbomsum-stat"><b className="mono">{m.specVersion}</b><span>{m.format || "CycloneDX"}</span></div>
        <div className="sbomsum-stat">
          <b className={(matchError || matches.length) ? "is-warn" : ""}>{matchError && !matches.length ? "?" : matches.length}</b>
          <span>{matchProgress
            ? `CVE matches · ${matchProgress.done}/${matchProgress.total} batches`
            : "CVE matches"}</span>
        </div>
        <div className="sbomsum-stat sbomsum-stat-wide"><b>{m.metadata.component.name}</b><span>primary component{m.metadata.component.version ? " · v" + m.metadata.component.version : ""}</span></div>
      </div>

      <SbomgenWarnings warnings={sbom.generated && sbom.generated.warnings} />
      {matchError && (
        <div className="login-err gen-err" role="alert" style={{ marginBottom: 12 }}>
          <Icon name="bell" size={14} />
          <span>{matchError}</span>
          {onRetryMatch && <button type="button" className="btn btn-sm gen-retry" onClick={onRetryMatch}><Icon name="refresh" size={13} />Try again</button>}
        </div>
      )}

      <div className="sbom-metaline">
        <span><Icon name="shield" size={13} />Schema valid</span>
        <span className="mono">{m.serialNumber}</span>
        <span>Generated {String(m.metadata.timestamp).slice(0, 10)} · {m.metadata.tool}</span>
        {/* Scanners list a package once per place they find it (Syft: once per build
            folder's .deps.json). Each appears here once; the file keeps every entry. */}
        {sbom.repeatsMerged > 0 && (
          <span title="The file lists some components more than once, once for every place they were found (for example each build folder). Each one is listed, matched and saved once here; the file itself is unchanged.">
            {sbom.repeatsMerged.toLocaleString()} repeated {sbom.repeatsMerged === 1 ? "entry" : "entries"} merged
          </span>
        )}
      </div>

      <div className="liblist">
        <div className="liblist-head"><span>Inventory preview</span>
          <button className="liblist-raw" onClick={() => setRaw(r => !r)}>{raw ? "Hide source" : "View source"}</button>
        </div>
        {raw ? (
          <>
            <pre className="sbom-raw mono">{sourceView ? sourceView.text : ""}</pre>
            {sourceView && sourceView.cut > 0 && (
              <div className="liblist-more">
                Showing the first {SOURCE_VIEW_MAX.toLocaleString()} of {sourceView.cut.toLocaleString()} characters · the SBOM itself is complete
              </div>
            )}
          </>
        ) : (
          previewRows.map(({ l, i }) => {
            const cves = answered(l) ? (liveCounts ? liveCounts.get(window.VC.normLib(l && l.name)) || 0 : 0) : null;
            return (
              <div className="librow" key={i}>
                <span className="librow-name mono">{l.name}{l.version ? <span className="librow-ver"> @{l.version}</span> : null}</span>
                <span className="librow-type">{l.type}</span>
                {cves === null
                  ? <span className="librow-pending mono" title={matchError || "Matching against the CVE ledger…"}>…</span>
                  : cves > 0
                    ? <span className="librow-cve"><Icon name="alert" size={12} />{cves}</span>
                    : <span className="librow-ok"><Icon name="check" size={12} /></span>}
              </div>
            );
          })
        )}
        {!raw && libs.length > PREVIEW_ROWS && (
          <div className="liblist-more liblist-more-row">
            <span>
              Showing {Math.min(shown, libs.length).toLocaleString()} of {libs.length.toLocaleString()} inventories
              {matchedKeys && matchedKeys.size ? " · most affected first" : ""}
            </span>
            <span className="liblist-more-actions">
              {shown < libs.length && (
                <button type="button" className="liblist-raw" onClick={() => setShown(s => s + PREVIEW_STEP)}>
                  Show {Math.min(PREVIEW_STEP, libs.length - shown).toLocaleString()} more
                </button>
              )}
              {shown < libs.length && libs.length - shown > PREVIEW_STEP && libs.length <= PREVIEW_ALL_MAX && (
                <button type="button" className="liblist-raw" onClick={() => setShown(libs.length)}>
                  Show all {libs.length.toLocaleString()}
                </button>
              )}
              {shown > PREVIEW_ROWS && (
                <button type="button" className="liblist-raw" onClick={() => setShown(PREVIEW_ROWS)}>Show fewer</button>
              )}
            </span>
          </div>
        )}
      </div>
    </div>
  );
}

/* ======================================================== step: Refine */
function EditStep({ libs, setLibs, matches, matchReady }) {
  return (
    <div className="wizform">
      <div className="wizform-head wizform-head-row">
        <div>
          <h2>Refine the inventory <span className="wiz-advanced">Advanced</span></h2>
          <p>Correct mis-parsed names, fix versions, or add inventories the scanner missed. Flagged rows reference a tracked CVE.</p>
        </div>
      </div>
      <SbomEditor value={libs} onChange={setLibs} embedded
        matches={matchReady ? matches : undefined} />
    </div>
  );
}

/* ========================================================= step: Match */
// The supplier only. It once also offered an "OEM" picker, filled with the names of the
// companies using this service: every customer saw every other customer's name.
function MatchStep({ supplier, setSupplier, suggested }) {

  // Quick-pick options = the suppliers this company chose for its earlier builds (from
  // /api/meta, its own picks only), plus the supplier parsed from THIS SBOM, deduped.
  // Anything not listed can still be typed in the free-text box below.
  const supplierOpts = [];
  const _seenSup = new Set();
  (window.VC.SUPPLIERS || []).concat(suggested ? [suggested] : []).forEach(s => {
    if (s && s !== "—" && !_seenSup.has(s)) { _seenSup.add(s); supplierOpts.push(s); }
  });

  return (
    <div className="wizform">
      <div className="wizform-head">
        <h2>Match to supplier</h2>
        <p>Attribute the component to the supplier that builds it — this is how it surfaces in the entity pivots.</p>
      </div>

      <div className="field">
        <label className="field-lab">Supplier <span className="field-req">required</span></label>
        <div className="field-wrap">
          <Icon name="package" size={16} className="field-ic" />
          <input value={supplier} onChange={e => setSupplier(e.target.value)}
            placeholder="Type the supplier that builds this component" />
        </div>
        {supplierOpts.length > 0 && (
          <div className="suppgrid" style={{ marginTop: 10 }}>
            {supplierOpts.map(s => (
              <button key={s} type="button" className={"suppcard" + (supplier === s ? " is-on" : "")} onClick={() => setSupplier(s)}>
                <span className="suppcard-av">{s.slice(0, 2).toUpperCase()}</span>
                <span className="suppcard-name">{s}</span>
                {s === suggested && <span className="suppcard-sug"><Icon name="sparkle" size={11} />from SBOM</span>}
              </button>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

/* ======================================================== step: Review */
// One screenful of matched CVEs. A real SBOM can match hundreds — this build hits
// 500 — and rendering them all made the review step a single unscrollable wall.
const REVIEW_PAGE_SIZE = 15;

// SEV_ORDER is DB-driven and applyAppConfig() mutates it in place, so it has to be read
// per call rather than baked into an index map at load time. An unrecognised band sorts
// LAST — never accidentally as "worst".
function sevRank(s) { const i = SEV_ORDER.indexOf(s); return i < 0 ? 99 : i; }

// Band first, then CVSS descending, then id for a stable tiebreak.
// cvss is genuinely nullable, so an unscored row sorts to the end of ITS OWN band: an
// unscored Critical still outranks every High. Sorting on the number first (null -> 0)
// would bury it on the last page, which is the opposite of what this screen is for.
function cmpMatch(a, b) {
  const ra = sevRank(a.vuln.severity), rb = sevRank(b.vuln.severity);
  if (ra !== rb) return ra - rb;
  const ca = typeof a.vuln.cvss === "number" ? a.vuln.cvss : -1;
  const cb = typeof b.vuln.cvss === "number" ? b.vuln.cvss : -1;
  if (ca !== cb) return cb - ca;
  return String(a.vuln.id).localeCompare(String(b.vuln.id));
}

function ReviewStep({ name, category, supplier, libs, matches, matchError, matchLoading = false, ecu = EMPTY_ECU }) {
  const sev = window.VC.matchSeverity(matches);
  const cat = catOf(category);

  // Same shape as the component detail page's matched-CVE list, so the two paginate
  // identically. <Pager> renders nothing while the rows fit on one page, so there is
  // no "is it worth showing" test here.
  const [page, setPage] = useState(1);
  const [openId, setOpenId] = useState(null);        // one row disclosed at a time
  const totalPages = Math.max(1, Math.ceil(matches.length / REVIEW_PAGE_SIZE));
  // A re-match can shrink the result set under the current page (edit the inventory,
  // come back). Clamp rather than render an empty page with rows that no longer exist.
  useEffect(() => { setPage(1); }, [matches]);
  useEffect(() => { if (page > totalPages) setPage(totalPages); }, [totalPages, page]);
  useEffect(() => { setOpenId(null); }, [page, matches]);

  // Matches arrive in BATCH-ARRIVAL order, which is effectively arbitrary — so the worst
  // finding in a 500-match SBOM could land on page 12. Ranking here makes page 1 the
  // actual triage page. Only the order changes: length, page size, <Pager> and the slice
  // are untouched.
  const ranked = useMemo(() => matches.slice().sort(cmpMatch), [matches]);
  const pageMatches = useMemo(
    () => ranked.slice((page - 1) * REVIEW_PAGE_SIZE, page * REVIEW_PAGE_SIZE),
    [ranked, page],
  );
  return (
    <div className="wizform">
      <div className={"rev-hero" + (matches.length || matchError ? "" : " is-clean")}>
        <div className="rev-hero-l">
          <span className="rev-hero-ic"><Icon name="sparkle" size={20} /></span>
          <div>
            <div className="rev-hero-num">{matchError ? "?" : matches.length}<span> matching {matches.length === 1 ? "vulnerability" : "vulnerabilities"} {matchError ? "— matching failed" : "found"}</span></div>
            <p className="rev-hero-sub">{matchError || `Correlated ${libs.length} SBOM inventories against the live CVE ledger (server-side).`}</p>
          </div>
        </div>
        <div className="rev-hero-sev">
          {SEV_ORDER.map(s => sev[s] > 0 && (
            <span key={s} className={"rev-sevchip " + sevClass(s)}><span className="rev-sevchip-dot" />{sev[s]} {s}</span>
          ))}
        </div>
      </div>

      <div className="rev-cols">
        <section className="rev-matches">
          <div className="rev-sec-head">
            <h3>Matched vulnerabilities</h3>
            {/* Says only what is true of what is on screen. Deliberately does NOT count
                distinct libraries: matches are deduped by CVE id, so a CVE affecting
                several inventories is kept once against whichever matched first — any
                per-library total would be an undercount presented as a fact. */}
            <span className="card-sub">
              {matchLoading ? "Ranking as batches arrive…" : "Ranked by severity, then CVSS"}
            </span>
          </div>
          {matches.length === 0 ? (
            <div className="empty"><Icon name={matchError ? "bell" : "check"} size={22} /><p>{matchError || "No tracked CVEs match this SBOM. The component will still be monitored as new CVEs arrive."}</p></div>
          ) : (
            <div className="rev-list">
              {pageMatches.map(({ vuln, lib }) => {
                const open = openId === vuln.id;
                const scored = typeof vuln.cvss === "number";
                const nameOnly = !lib.version;
                return (
                  <div className={"rev-item " + sevClass(vuln.severity) + (open ? " is-open" : "")} key={vuln.id}>
                    <button type="button" className="rev-row" aria-expanded={open}
                      onClick={() => setOpenId(open ? null : vuln.id)}
                      aria-label={vuln.id + ", " + vuln.severity
                        + (scored ? ", CVSS " + vuln.cvss.toFixed(1) : ", not scored")
                        + ", via " + lib.name + (nameOnly ? ", no version in the SBOM" : " version " + lib.version)}>

                      {/* Severity carries its band colour — tinted fill, border and text,
                          all from --sc, so Critical/High/Medium/Low are distinguishable at
                          a glance. A soft tint rather than a solid block: twenty solid red
                          pills is what stopped colour meaning anything in the old build. */}
                      <span className="rev-sev">{vuln.severity}</span>

                      {/* Kept as its own right-aligned tabular column so 7.5 / 9.8 / 10.0
                          line up and the page reads as a ranking. */}
                      {scored
                        ? <span className="rev-cvss">{vuln.cvss.toFixed(1)}</span>
                        : <span className="rev-cvss rev-cvss-none" title="Not scored">—</span>}

                      <span className="rev-id mono">{vuln.id}</span>
                      <span className="rev-desc">{vuln.description || "No description published."}</span>

                      {/* No literal "@" in this markup. Name and version are separate
                          elements and the separator is a CSS ::before on the version, so
                          "no version" means "no separator" — the dangling "Lib@" in the
                          old build is structurally impossible here, not patched away. */}
                      <span className="rev-lib" title={lib.name + (nameOnly ? " — no version stated in the SBOM" : " " + lib.version)}>
                        <span className="rev-lib-name mono">{lib.name}</span>
                        {nameOnly
                          ? <span className="rev-lib-nover">no version</span>
                          : <span className="rev-lib-ver mono">{lib.version}</span>}
                      </span>

                      <Icon name="chevD" size={13} className="rev-chev" />
                    </button>

                    {open && (
                      <div className="rev-detail">
                        <dl className="rev-detail-meta">
                          <div><dt>Severity</dt><dd>{vuln.severity}</dd></div>
                          <div><dt>CVSS</dt>
                            <dd className={scored ? "mono" : "rev-detail-none"}>{scored ? vuln.cvss.toFixed(1) : "Not scored"}</dd>
                          </div>
                          <div><dt>Package</dt><dd className="mono">{lib.name}</dd></div>
                          <div><dt>Version</dt>
                            <dd className={nameOnly ? "rev-detail-none" : "mono"}>{lib.version || "Not stated in SBOM"}</dd>
                          </div>
                          {lib.type && <div><dt>Type</dt><dd>{lib.type}</dd></div>}
                          {lib.supplier && lib.supplier !== "—" && <div><dt>Supplier</dt><dd>{lib.supplier}</dd></div>}
                          {lib.purl && (
                            <div className="rev-detail-wide"><dt>purl</dt>
                              {/* makeLib() emits pkg:generic/name@ on a blank version —
                                  the same dangling "@", killed at the point of display. */}
                              <dd className="mono">{String(lib.purl).replace(/@$/, "")}</dd>
                            </div>
                          )}
                        </dl>

                        {/* Match confidence, stated rather than hidden. Both facts are real
                            limits of this pipeline, and a review step is exactly where the
                            reviewer should be told about them. */}
                        <p className="rev-detail-note">
                          {nameOnly
                            ? "Matched on package name alone — this SBOM states no version, so the match is broader than usual."
                            : "Matched on package name and version."}
                          {" "}Listed against the first inventory it matched; a CVE affecting several inventories appears once.
                        </p>

                        <p className="rev-detail-desc">{vuln.description || "No description published for this CVE."}</p>
                      </div>
                    )}
                  </div>
                );
              })}
            </div>
          )}
          <Pager page={page} total={matches.length} pageSize={REVIEW_PAGE_SIZE} onPage={setPage} />
        </section>

        <aside className="rev-summary">
          <div className="rev-sec-head"><h3>Component</h3></div>
          <div className="rev-card">
            <div className="rev-card-name">{name || "Untitled component"}</div>
            {cat && <span className="kindtag kindtag-auto rev-card-cat"><Icon name={cat.icon} size={12} />{cat.label}</span>}
            <div className="rev-meta">
              <div className="metaline"><span>Supplier</span><b>{supplier || "—"}</b></div>
              <div className="metaline"><span>Inventories</span><b className="mono">{libs.length}</b></div>
              {ECU_FIELDS.filter(f => (ecu[f.id] || "").trim()).map(f => (
                <div className="metaline" key={f.id}><span>{f.label}</span><b className="mono">{ecu[f.id].trim()}</b></div>
              ))}
            </div>
          </div>
        </aside>
      </div>
    </div>
  );
}

/* ===================================================== live summary (stepper) */
function WizSummary({ name, category, sbom, libs, supplier, matches }) {
  const cat = catOf(category);
  return (
    <aside className="wiz-summary">
      <div className="wiz-summary-head">Component draft</div>
      <div className="wiz-sum-row"><span>Name</span><b>{name || "—"}</b></div>
      <div className="wiz-sum-row"><span>Type</span><b>{cat ? cat.label : "—"}</b></div>
      <div className="wiz-sum-row"><span>SBOM</span><b>{sbom ? `${libs.length} inventories` : "—"}</b></div>
      <div className="wiz-sum-row"><span>Supplier</span><b>{supplier || "—"}</b></div>
      <div className="wiz-sum-div" />
      <div className="wiz-sum-match">
        <span className="wiz-sum-match-num">{sbom ? matches.length : "—"}</span>
        <span className="wiz-sum-match-lab">matching {matches.length === 1 ? "CVE" : "CVEs"}</span>
      </div>
    </aside>
  );
}

/* ========================================================= wizard shell */
function NewComponentWizard({ layout, onCancel, onCreate }) {
  const [step, setStep] = useState(0);
  const [name, setName] = useState("");
  const [category, setCategory] = useState("");
  const [sbom, setSbom] = useState(null);
  const [libs, setLibs] = useState([]);
  const [supplier, setSupplier] = useState("");
  const [ecu, setEcu] = useState(EMPTY_ECU);              // optional ECU fields (Identity step)

  // Matching now runs SERVER-SIDE: the full CVE catalog is no longer shipped to the
  // browser (data isolation), so the old client-side window.VC.correlateSbom() would
  // always find 0. We POST the parsed libraries to /api/components/preview, which runs
  // the real matching pipeline against the catalog and rolls it back (nothing is saved).
  const [matches, setMatches] = useState([]);
  const [matchLoading, setMatchLoading] = useState(false);
  // A failed preview is NOT the same answer as a clean SBOM. Swallowing the rejection
  // rendered a broken matching pipeline as a confident "0 matching vulnerabilities",
  // which is the worst possible failure mode for this screen — so the error is kept
  // and shown instead of the count.
  const [matchError, setMatchError] = useState("");
  // Which libraries have actually been through the matcher so far. A row that is not
  // in here has no answer yet — and "no answer yet" must never render as a green tick.
  const [matchedKeys, setMatchedKeys] = useState(null);
  const [matchProgress, setMatchProgress] = useState(null);   // { done, total }
  const [matchTries, setMatchTries] = useState(0);            // bumped by "Try again" after a failed match

  useEffect(() => {
    if (!sbom || !libs.length) {
      setMatches([]); setMatchError(""); setMatchLoading(false);
      setMatchedKeys(null); setMatchProgress(null);
      return;
    }
    let cancelled = false;
    setMatchLoading(true); setMatchError("");
    setMatches([]); setMatchedKeys(new Set()); setMatchProgress(null);

    // Sent in batches rather than as one request, so results appear as they arrive
    // instead of the whole screen sitting blank until the last library is done.
    // Server-side matching is ~0.3s for a 215-library SBOM since the pg_trgm index
    // (migration 0013); the wait the user actually sees is the round trip — inserting
    // the transient rows, reading the matches back, and shipping them over. Batching
    // turns one long silence into steady updates.
    const stripped = libs.map(({ _id, ...l }) =>
      ({ ...l, supplier: !l.supplier || l.supplier === "—" ? null : l.supplier }));
    const batches = [];
    for (let i = 0; i < stripped.length; i += MATCH_BATCH) batches.push(stripped.slice(i, i + MATCH_BATCH));

    (async () => {
      const seen = new Set();      // dedupe by CVE id ACROSS batches
      const acc = [];
      const done = new Set();
      let failures = 0, lastErr = null;

      for (let b = 0; b < batches.length; b++) {
        if (cancelled) return;
        try {
          const ms = await window.VCApi.previewMatches({
            name: name.trim() || "preview",
            libs: batches[b],
          });
          if (cancelled) return;
          (Array.isArray(ms) ? ms : []).forEach((m) => {
            const id = m && m.vuln && m.vuln.id;
            if (!id || seen.has(id)) return;
            seen.add(id);
            acc.push(m);
          });
        } catch (e) {
          if (cancelled) return;
          failures++; lastErr = e;
        }
        // Mark the batch processed either way: a library whose batch FAILED has no
        // answer coming, so it must stay pending rather than flip to a false tick.
        if (failures === 0 || b < batches.length) {
          batches[b].forEach((l) => { const k = window.VC.normLib(l && l.name); if (k) done.add(k); });
        }
        setMatches(acc.slice());
        setMatchedKeys(new Set(done));
        setMatchProgress({ done: b + 1, total: batches.length });
      }

      if (cancelled) return;
      if (failures === batches.length) {
        setMatches([]); setMatchedKeys(new Set());
        setMatchError((lastErr && lastErr.message)
          ? "Could not match this SBOM against the CVE ledger: " + lastErr.message
          : "Could not match this SBOM against the CVE ledger. The count below is not a result.");
      } else if (failures > 0) {
        setMatchError(`${failures} of ${batches.length} batches failed — the count below is incomplete.`);
      }
      setMatchLoading(false);
      setMatchProgress(null);
    })();

    return () => { cancelled = true; };
  }, [sbom, libs, matchTries]);

  function loadSbom(parsed) {
    // Every component is matched and saved, each package once (sbomAllLibs).
    const all = sbomAllLibs(parsed.libs, parsed.relationships);
    const withIds = all.libs.map((c, i) => ({ ...c, _id: "l" + i }));
    setSbom({ meta: parsed.meta, raw: parsed.raw, filename: parsed.filename,
      relationships: all.relationships, repeatsMerged: (parsed.repeatsMerged || 0) + all.merged,
      format: /\.xml$/i.test(parsed.filename) ? "xml" : "json",
      generated: parsed.generated || null });   // set when it came from source code
    setLibs(withIds);
    const sup = parsed.meta && parsed.meta.metadata && parsed.meta.metadata.component && parsed.meta.metadata.component.supplier;
    if (!supplier && sup && sup !== "—") setSupplier(sup);
  }
  function clearSbom() { setSbom(null); setLibs([]); }

  const unlocked = (i) => [
    true,
    !!(name.trim() && category),
    !!sbom, !!sbom,
    !!(sbom && supplier),
  ][i];

  function goTo(i) { if (unlocked(i)) { setStep(i); window.scrollTo(0, 0); } }

  function create() {
    const saved = libs.map(({ _id, ...l }) => ({ ...l, supplier: !l.supplier || l.supplier === "—" ? null : l.supplier }));
    onCreate({
      id: "cmp-" + Math.random().toString(36).slice(2, 7),
      name: name.trim() || "Untitled component", category, supplier,
      sbomName: sbom.filename,
      spec: sbom.meta.format === "CSV" ? "CSV" : `${sbom.meta.format || "CycloneDX"} ${sbom.meta.specVersion}`,
      libs: saved,
      // The SBOM itself, kept with the build so it can be downloaded as it was, and how
      // its components relate.
      document: sbomDocumentFor(sbom, ecu),
      relationships: sbomEdgesFor(sbom, saved),
      createdAt: new Date().toISOString().slice(0, 10),
    });
  }

  const stepId = WIZ_STEPS[step].id;
  let body = null;
  if (stepId === "identity") body = <IdentityStep {...{ name, setName, category, setCategory, ecu, setEcu }} />;
  else if (stepId === "sbom") body = <UploadStep sbom={sbom} libs={libs} matches={matches} matchLoading={matchLoading}
    matchError={matchError} matchedKeys={matchedKeys} matchProgress={matchProgress}
    onParsed={loadSbom} onClear={clearSbom} onRetryMatch={() => setMatchTries((n) => n + 1)} componentName={name.trim()} />;
  else if (stepId === "refine") body = <EditStep libs={libs} setLibs={setLibs}
    matches={matches} matchReady={!matchLoading && !matchError} />;
  else if (stepId === "match") body = <MatchStep {...{ supplier, setSupplier }} suggested={sbom?.meta?.metadata?.component?.supplier} />;
  else body = <ReviewStep {...{ name, category, supplier, libs, matches, matchError, matchLoading, ecu }} />;

  // step-aware footer ------------------------------------------------------
  let footer;
  if (stepId === "identity") footer = (
    <><button className="btn btn-ghost" onClick={onCancel}>Cancel</button><span className="wiz-foot-sp" />
      <button className="btn btn-accent" disabled={!unlocked(1)} onClick={() => goTo(1)}>Continue<Icon name="arrowR" size={15} /></button></>
  );
  else if (stepId === "sbom") footer = (
    <><button className="btn btn-ghost" onClick={() => goTo(0)}><Icon name="arrowL" size={15} />Back</button><span className="wiz-foot-sp" />
      <button className="btn" disabled={!sbom} onClick={() => goTo(2)}><Icon name="pencil" size={14} />Open SBOM editor</button>
      <button className="btn btn-accent" disabled={!sbom} onClick={() => goTo(3)}>Continue to matching<Icon name="arrowR" size={15} /></button></>
  );
  else if (stepId === "refine") footer = (
    <><button className="btn btn-ghost" onClick={() => goTo(1)}><Icon name="arrowL" size={15} />Back</button><span className="wiz-foot-sp" />
      <button className="btn btn-accent" onClick={() => goTo(3)}>Continue to matching<Icon name="arrowR" size={15} /></button></>
  );
  else if (stepId === "match") footer = (
    <><button className="btn btn-ghost" onClick={() => goTo(1)}><Icon name="arrowL" size={15} />Back</button><span className="wiz-foot-sp" />
      <button className="btn btn-accent" disabled={!supplier} onClick={() => goTo(4)}>Review matches<Icon name="arrowR" size={15} /></button></>
  );
  else footer = (
    <><button className="btn btn-ghost" onClick={() => goTo(3)}><Icon name="arrowL" size={15} />Back</button><span className="wiz-foot-sp" />
      <button className="btn btn-accent" onClick={create}><Icon name="check" size={15} />Create component</button></>
  );

  const steps = (
    WIZ_STEPS.map((s, i) => {
      const state = i < step ? "done" : i === step ? "cur" : "todo";
      return (
        <button key={s.id} className={"wizstep is-" + state + (unlocked(i) ? " is-unlocked" : "")}
          onClick={() => goTo(i)} disabled={!unlocked(i)}>
          <span className="wizstep-dot">{i < step ? <Icon name="check" size={14} /> : <Icon name={s.icon} size={14} />}</span>
          <span className="wizstep-tx"><span className="wizstep-lab">{s.label}{s.optional && <em> · optional</em>}</span><span className="wizstep-sub">{s.sub}</span></span>
        </button>
      );
    })
  );

  if (layout === "stepper") {
    return (
      <div className="page wiz wiz-stepper-layout">
        <div className="wiz-top">
          <div><span className="eyebrow">Workflow</span><h1 className="page-title">New component</h1></div>
          <button className="btn btn-ghost btn-sm" onClick={onCancel}><Icon name="close" size={14} />Cancel</button>
        </div>
        <div className="wiz-stepper">{steps}</div>
        <div className="wiz-body-stepper">
          <div className="wiz-panel">{body}<div className="wiz-foot">{footer}</div></div>
          <WizSummary {...{ name, category, sbom, libs, supplier, matches }} />
        </div>
      </div>
    );
  }

  return (
    <div className="page wiz wiz-rail-layout">
      <div className="wiz-top">
        <div><span className="eyebrow">Workflow</span><h1 className="page-title">New component</h1></div>
        <button className="btn btn-ghost btn-sm" onClick={onCancel}><Icon name="close" size={14} />Cancel</button>
      </div>
      <div className="wiz-body-rail">
        <div className="wiz-rail">{steps}</div>
        <div className="wiz-panel">{body}<div className="wiz-foot">{footer}</div></div>
      </div>
    </div>
  );
}

window.NewComponentWizard = NewComponentWizard;
