// sbom/update_sbom.jsx — modal to update an existing component's SBOM.
// Three paths: edit the inventory inline, upload a new CycloneDX scan, or generate one
// from the source code (sbom/generate_sbom.jsx). On apply the component is re-matched. A CVE that no longer matches is reported as REMEDIATED only
// when its library is still shipped at a fixed version; one whose library was removed
// from the inventory is reported separately as out of scope and keeps its status.

function UpdateSbomModal({ comp, onClose, onApply }) {
  const [mode, setMode] = useState("edit");          // edit | upload | generate
  // The last SBOM generated from source in this dialog, kept so it can be downloaded:
  // the server stores nothing it generates.
  const [generated, setGenerated] = useState(null);
  // The SBOM file the inventory now comes from, when one was uploaded or generated here
  // ({ meta, raw, filename, relationships }). It is kept with the new build; after hand
  // edits alone there is none, since no file describes that inventory.
  const [source, setSource] = useState(null);
  // A new SBOM replaces the inventory with every component it lists, each package once,
  // the same rule as the wizard (sbomAllLibs). Returns the SBOM with its edges re-pointed
  // at the entries kept, to be the build's source.
  function loadAll(parsed) {
    const all = sbomAllLibs(parsed.libs, parsed.relationships);
    setLibs(all.libs.map((l, i) => ({ ...l, _id: "u" + i })));
    return { ...parsed, relationships: all.relationships, count: all.libs.length };
  }
  const [parsing, setParsing] = useState(false);
  const [libs, setLibs] = useState(() => comp.libs.map((l, i) => ({ ...l, _id: "u" + i })));

  // BOTH sides of this comparison must come from the SERVER matcher.
  //
  // This modal used to call window.VC.correlateSbom() for both, which is the old
  // client-side correlator. Matching moved server-side (the full catalog is no longer
  // shipped to the browser) and it uses a different rule entirely, so the numbers here
  // disagreed with the component page behind the modal — it showed 500 matched CVEs
  // while this said "2 matches now, was 2". That was not only a wrong display: apply()
  // hands `matches` up to replace the component's real match set, and writes
  // `remediatedIds` straight to the database, so applying wrote triage decisions
  // derived from a match set the server never produced.
  //
  // Baseline: what the server already matched for this component. No recomputation.
  const baseMatches = useMemo(
    () => (Array.isArray(comp.matches) ? comp.matches : []), [comp]);

  // After: the same /api/components/preview call the new-component wizard uses, so an
  // edit is scored by exactly the pipeline that will score it on apply. Debounced —
  // the inline editor changes `libs` on every keystroke and this is an expensive query.
  const [newMatches, setNewMatches] = useState(baseMatches);
  const [matchLoading, setMatchLoading] = useState(false);
  const [matchError, setMatchError] = useState("");
  const [matchTries, setMatchTries] = useState(0);        // bumped by "Try again" after a failed re-match
  const strippedLibs = useMemo(() => libs.map(({ _id, ...l }) => l), [libs]);
  const libsKey = useMemo(() => JSON.stringify(strippedLibs), [strippedLibs]);

  useEffect(() => {
    // Unedited: the baseline IS the answer, so don't spend a preview to re-derive it.
    if (libsKey === JSON.stringify(comp.libs)) {
      setNewMatches(baseMatches); setMatchError(""); setMatchLoading(false);
      return;
    }
    if (!strippedLibs.length) {
      setNewMatches([]); setMatchError(""); setMatchLoading(false);
      return;
    }
    let cancelled = false;
    setMatchLoading(true);
    const timer = setTimeout(() => {
      window.VCApi.previewMatches({
        name: comp.name || "preview",
        libs: strippedLibs.map(l => ({ ...l, supplier: !l.supplier || l.supplier === "—" ? null : l.supplier })),
      })
        .then((ms) => { if (!cancelled) { setNewMatches(Array.isArray(ms) ? ms : []); setMatchError(""); } })
        .catch((e) => {
          if (cancelled) return;
          // A failed preview is NOT "0 matches". Rendering it as one would offer to
          // remediate every CVE the component has, so the error is kept and apply is
          // blocked on it. Same rule the wizard follows.
          setNewMatches([]);
          setMatchError((e && e.message)
            ? "Could not re-match this SBOM: " + e.message
            : "Could not re-match this SBOM against the CVE ledger.");
        })
        .finally(() => { if (!cancelled) setMatchLoading(false); });
    }, 600);
    return () => { cancelled = true; clearTimeout(timer); };
  }, [libsKey, comp, baseMatches, matchTries]);

  const baseIds = useMemo(() => new Set(baseMatches.map(m => m.vuln.id)), [baseMatches]);
  const newIds = useMemo(() => new Set(newMatches.map(m => m.vuln.id)), [newMatches]);
  const added = newMatches.filter(m => !baseIds.has(m.vuln.id));

  // A CVE that stops matching has stopped for one of two reasons, and they are NOT
  // the same fact about the vehicle:
  //
  //   remediated — the library is still shipped, at a version past the fix. Something
  //                was actually done about the finding, so its triage status moves to
  //                Remediated.
  //   removed    — the library is no longer in the inventory at all (a row deleted
  //                here, or dropped by a re-scan). Nothing was fixed; this build just
  //                no longer contains the vulnerable code. Marking that "remediated"
  //                writes a false claim into the ledger — the CVE is still open for
  //                every OTHER component that ships the same library, and an audit
  //                reads Remediated as "a fix shipped".
  //
  // So only the first group is sent as a status change; the second is reported as
  // out of scope and left at whatever status it already had.
  const presentLibs = useMemo(() => {
    const s = new Set();
    libs.forEach(l => { const k = window.VC.normLib(l.name); if (k) s.add(k); });
    return s;
  }, [libs]);
  // A scan that says it is partial confirms neither. A library missing from it may simply
  // not have been read, and a fixed version beside firmware it skipped may not be the only
  // one shipped. A Remediated written from that is company-wide, and moves the Jira issue.
  const partial = !!(generated && generated.warnings && generated.warnings.length);
  const dropped = baseMatches.filter(m => !newIds.has(m.vuln.id));
  const remediated = partial ? [] : dropped.filter(m => presentLibs.has(window.VC.normLib(m.lib.name)));
  const removed = partial ? dropped : dropped.filter(m => !presentLibs.has(window.VC.normLib(m.lib.name)));

  const strip = (arr) => arr.map(({ _id, ...l }) => l);
  const dirty = JSON.stringify(strip(libs)) !== JSON.stringify(comp.libs);

  // Deleting SOME inventory rows is a normal edit and saves as a new build. Deleting
  // them ALL cannot: the schema stores one `component` row per library, so a build
  // with an empty inventory has nothing to store and the server rejects it with
  // "At least one SBOM component (library) is required."
  //
  // That rejection only arrived after clicking Apply, as a toast, which read as
  // "the version did not change" rather than as a rule. Say it up front and disable
  // the button instead.
  const emptyInventory = strippedLibs.length === 0;

  // Reads the file the user actually chose.
  //
  // This used to call window.VC.simulateUpdatedSbom(), which ignored the file entirely,
  // waited 800ms and INVENTED patched version numbers for the current inventory. Both
  // buttons did it, for any file or none. Those fabricated versions then flowed through
  // apply() into remediatedIds, which is written to the database — so the demo path was
  // capable of recording remediations that never happened.
  //
  // readSbomFile() is the wizard's reader (sbom/wizard_components.jsx, loaded before this
  // file): the server parses the file, the browser keeps its text as the document.
  const fileRef = useRef(null);
  const [uploadErr, setUploadErr] = useState("");
  const readRef = useRef(null);
  useEffect(() => () => { if (readRef.current) readRef.current.abort(); }, []);

  async function onFilePicked(e) {
    const file = e.target.files && e.target.files[0];
    e.target.value = "";                    // re-picking the same file must re-fire
    if (!file) return;
    setUploadErr(""); setParsing(true);
    const ctrl = new AbortController();
    readRef.current = ctrl;
    try {
      const parsed = await readSbomFile(file, ctrl.signal);
      if (ctrl.signal.aborted) return;
      setSource(loadAll(parsed));
      setGenerated(null);                   // the inventory no longer comes from that scan
      setMode("edit");                      // land in the editor so the diff is reviewable
    } catch (err) {
      if (ctrl.signal.aborted || (err && err.aborted)) return;
      if (err && err.status === 401 && window.__vcOn401) { window.__vcOn401(); return; }
      setUploadErr(sbomgenError(err));
    } finally {
      if (readRef.current === ctrl) readRef.current = null;
      if (!ctrl.signal.aborted) setParsing(false);
    }
  }

  // A generated SBOM replaces the inventory exactly like an uploaded one, and lands in
  // the editor for the same reason: the diff must be reviewable before it is applied.
  function onGenerated(parsed) {
    const loaded = loadAll(parsed);
    setSource(loaded);
    setGenerated({ ...parsed.generated, filename: parsed.filename, raw: parsed.raw, count: loaded.count });
    setUploadErr("");
    setMode("edit");
  }

  function apply() {
    const saved = strip(libs);
    // The ECU fields belong to the ECU, not to one build of it: carried over as they are.
    const d = comp.document || {};
    const ecu = { hwPartNumber: d.hwPartNumber, swPartNumber: d.swPartNumber, mcu: d.mcu, variantId: d.variantId };
    const hasEcu = Object.values(ecu).some(Boolean);
    onApply({
      libs: saved,
      remediatedIds: remediated.map(m => m.vuln.id),
      removedIds: removed.map(m => m.vuln.id),
      partial,
      sbomDocument: source || hasEcu ? sbomDocumentFor(source, ecu) : null,
      relationships: source ? sbomEdgesFor(source, saved) : [],
    });
  }

  return (
    <div className="usm-scrim" onClick={onClose}>
      <div className="usm" onClick={e => e.stopPropagation()}>
        <div className="usm-head">
          <div>
            <span className="eyebrow">Update SBOM</span>
            <h3>{comp.name}</h3>
          </div>
          <button className="iconbtn" onClick={onClose} title="Close"><Icon name="close" size={16} /></button>
        </div>

        <div className="usm-tabs">
          <button className={"seg-btn" + (mode === "edit" ? " is-on" : "")} onClick={() => setMode("edit")}><Icon name="pencil" size={14} />Edit inline</button>
          <button className={"seg-btn" + (mode === "upload" ? " is-on" : "")} onClick={() => setMode("upload")}><Icon name="upload" size={14} />Upload new file</button>
          <button className={"seg-btn" + (mode === "generate" ? " is-on" : "")} onClick={() => setMode("generate")}><Icon name="package" size={14} />Generate from source</button>
        </div>

        <div className="usm-body">
          {parsing ? (
            <div className="drop is-parsing"><span className="btn-spin drop-spin" /><strong>Parsing updated scan…</strong>
              <span className="drop-sub">Reading the file · rebuilding the inventory</span></div>
          ) : mode === "upload" ? (
            <div className="drop">
              <span className="drop-ic"><Icon name="upload" size={26} /></span>
              <strong>Upload an updated CycloneDX or SPDX scan</strong>
              <span className="drop-sub">Accepts <b>.cdx.json</b>, <b>.cdx.xml</b> (CycloneDX) · <b>.spdx.json</b> (SPDX 2.2 – 2.3, 3.0.1) · or a <b>.csv</b> component list — replaces the current inventory</span>
              <input ref={fileRef} type="file" hidden onChange={onFilePicked}
                accept={window.VCApi.SBOM_FILE_ACCEPT} />
              <div className="drop-actions">
                <button className="btn btn-accent" onClick={() => fileRef.current && fileRef.current.click()}>
                  <Icon name="doc" size={14} />Choose file…</button>
              </div>
              <SbomgenErr err={uploadErr} style={{ marginTop: 10 }} />
            </div>
          ) : mode === "generate" ? (
            <SourceSbomGenerator compact componentName={comp.name} onGenerated={onGenerated} />
          ) : (
            <>
              {generated && (
                <div className="usm-genbar">
                  <Icon name="check" size={14} />
                  <span>Inventory replaced with the {generated.count} components {generated.toolName} found
                    in <span className="mono">{generated.source}</span>
                    {generated.sourceDetectedCount > 0 && <> ({generated.sourceDetectedCount} of them by the C/C++ source check)</>}
                    {generated.binaryDetectedCount > 0 && (
                      <span title={sbomBinaryTitle(generated.binary)}> ({generated.binaryDetectedCount} of them in compiled code)</span>)}.
                    Review the changes, then apply.</span>
                  <SbomDownloadMenu options={generatedSbomOptions(generated.filename, generated.raw,
                    source && source.meta && source.meta.specVersion, generated.inventory)} />
                </div>
              )}
              {generated && <SbomgenWarnings warnings={generated.warnings} />}
              <SbomEditor value={libs} onChange={setLibs} embedded
                matches={matchLoading || matchError ? undefined : newMatches} />
            </>
          )}
        </div>

        <div className="usm-impact">
          <div className="usm-impact-stats">
            <span className="usm-stat">
              {matchLoading
                ? <b className="mono">…</b>
                : <b className={newMatches.length < baseMatches.length ? "is-good" : ""}>{newMatches.length}</b>}
              {matchLoading ? " re-matching" : " matches now"}
              <em>was {baseMatches.length}</em></span>
            {!matchLoading && !matchError && (
              <span className="usm-pill usm-pill-good" title="Library still shipped, at a version past the fix — status moves to Remediated">
                <Icon name="check" size={13} />{remediated.length} to remediate</span>
            )}
            {!matchLoading && !matchError && removed.length > 0 && (
              <span className="usm-pill usm-pill-mute" title={partial ? "This scan did not read everything, so it cannot say these are gone. Their status is left as it stands." : "Library no longer in this inventory — no longer in scope for this component, and its triage status is left unchanged"}>
                <Icon name="trash" size={13} />{removed.length} {partial ? "not confirmed" : "removed with library"}</span>
            )}
            {!matchLoading && !matchError && added.length > 0 && <span className="usm-pill usm-pill-warn"><Icon name="alert" size={13} />{added.length} new</span>}
          </div>
          {matchError && (
            <div className="usm-matcherr">
              <Icon name="alert" size={14} /><span>{matchError}</span>
              <button type="button" className="btn btn-sm gen-retry" onClick={() => setMatchTries((n) => n + 1)}><Icon name="refresh" size={13} />Try again</button>
            </div>
          )}
          {!matchLoading && !matchError && remediated.length > 0 && (
            <div className="usm-remlist">
              {remediated.map(m => (
                <span className="usm-remchip" key={m.vuln.id}><span className={"chip-dot " + sevClass(m.vuln.severity)} />{m.vuln.id}</span>
              ))}
            </div>
          )}
          {!matchLoading && !matchError && removed.length > 0 && (
            <>
              <span className="usm-remnote">
                {partial
                  ? "No longer matched, but this scan did not read everything. No fix is recorded and their status is left as it stands."
                  : "Dropped with their library — no fix is recorded and their status is left as it stands."}
              </span>
              <div className="usm-remlist">
                {removed.map(m => (
                  <span className="usm-remchip is-muted" key={m.vuln.id}><span className={"chip-dot " + sevClass(m.vuln.severity)} />{m.vuln.id}</span>
                ))}
              </div>
            </>
          )}
        </div>

        <div className="usm-foot">
          {/* Both halves of the merge matter here. The re-match states come first
              because they gate the button: apply writes remediatedIds to the database,
              so a match set the server has not confirmed must never be applied. Once
              the match IS confirmed, say what applying actually does — it saves a NEW
              build rather than editing this one in place. */}
          <span className="usm-foot-note">
            {emptyInventory ? "An SBOM must keep at least one component — a build with an empty inventory cannot be saved."
              : matchError ? "Re-match failed — apply is blocked until it succeeds."
              : matchLoading ? "Re-matching against the CVE ledger…"
              : dirty ? "Saved as a new version of " + comp.name + " — the current build is kept."
              : "No changes yet."}
          </span>
          <span className="wiz-foot-sp" />
          <button className="btn btn-ghost" onClick={onClose}>Cancel</button>
          {/* Blocked on a pending or failed re-match, not just on `dirty`: apply writes
              remediatedIds to the database, so a match set the server has not confirmed
              would persist triage decisions that were never true. */}
          <button className="btn btn-accent" disabled={!dirty || matchLoading || !!matchError || emptyInventory}
            onClick={apply}><Icon name="refresh" size={15} />Apply &amp; re-match</button>
        </div>
      </div>
    </div>
  );
}

window.UpdateSbomModal = UpdateSbomModal;
