// screens/components.jsx — Components inventory (list) + component detail page.
function catMeta(id) { return window.VC.COMPONENT_CATEGORIES.find(c => c.id === id) || { label: id, icon: "package" }; }

// Rows per page for both long lists on the detail screen — the matched CVEs and the
// SBOM inventory. A single ECU build routinely matches hundreds of CVEs and carries
// thousands of libraries; unpaged, either list buries everything below it on the
// page, including the team's own comments.
const DETAIL_PAGE_SIZE = 15;

// The API returns at most this many matches per component, ordered by CVSS
// descending (backend/src/ThreatCore.Api/Services/Tenancy/ComponentDataService.cs:24
// MaxMatchesPerComponent, applied at :166 and :538 as .Take()). ComponentDto
// carries no total — the backend computes one into a local and spends it on a log
// line — so a card sitting on this number is showing a FLOOR, not a count, and its
// severity split is a top-N-by-CVSS slice of the real distribution.
const MATCH_CAP = 500;

// Risk state of a whole component, as a single-select filter: "affected" is any
// component the correlation engine matched at least one CVE to, "clean" the rest.
const COMP_RISK_FILTERS = [["all", "All"], ["affected", "Has CVE matches"], ["clean", "No matches"]];
const COMP_SORTS = [["risk", "Risk"], ["matches", "CVE matches"], ["cvss", "Max CVSS"], ["libs", "Inventory size"], ["name", "Name"], ["new", "Newest"]];
// Severity-weighted ordering for the default "Risk" sort: one Critical outranks any
// number of Highs, and so on down. Keeps the worst builds at the top of the grid
// rather than whichever order the server happened to return them in.
function compRiskScore(sev) { return sev.Critical * 1e6 + sev.High * 1e4 + sev.Medium * 1e2 + sev.Low; }

// The card's accessible name. Without one it is computed from its contents: the
// bar segments' title= strings read the severity tally out, then the legend reads
// the identical tally again, and the OEM — the only token separating two
// identically-named builds — lands a dozen tokens in. This mirrors exactly what
// the card shows and nothing more: once aria-label is set, a button's descendants
// are no longer announced separately, so anything left out here is gone for
// screen-reader users.
function compCardLabel({ c, cat, matches, sev, maxCvss, capped }) {
  // Supplier defaults to an em dash server-side, so guard it or the card announces
  // "supplier em dash".
  const sup = c.supplier && c.supplier !== "—" ? `, supplier ${c.supplier}` : "";
  const risk = matches.length
    ? `${matches.length}${capped ? " or more" : ""} CVE matches, worst CVSS ${maxCvss.toFixed(1)}, `
      + SEV_ORDER.map(s => `${sev[s]} ${s}`).join(", ")
    : "No matching CVEs";
  const oem = (c.brands || []).length ? `OEM ${c.brands.join(", ")}` : "no OEM";
  return `${c.name}. ${cat.label}${sup}. ${risk}. ${c.libs.length} SBOM entries, ${oem}. Open component.`;
}

function ComponentsScreen({ components, loading, onNew, onOpen }) {
  const [q, setQ] = useState("");
  const [catSel, setCatSel] = useState(new Set());
  const [supSel, setSupSel] = useState(new Set());
  const [brandSel, setBrandSel] = useState(new Set());
  const [sevSel, setSevSel] = useState(new Set());
  const [riskF, setRiskF] = useState("all");
  const [sort, setSort] = useState("risk");

  function toggle(set, val, setter) {
    const n = new Set(set); n.has(val) ? n.delete(val) : n.add(val); setter(n);
  }

  // Correlation is the expensive part of this page (a single build carries thousands
  // of libraries), so every component's matches, severity tally and max CVSS are
  // derived ONCE here and then shared by the filters, the sort and the cards.
  const rows = useMemo(() => (components || []).map(c => {
    // Prefer the backend's real, precomputed matches; fall back to the client-side
    // correlation engine (used for not-yet-saved components).
    const fromServer = Array.isArray(c.matches);
    const matches = fromServer ? c.matches : window.VC.correlateSbom(c.libs);
    const sev = window.VC.matchSeverity(matches);
    return { c, cat: catMeta(c.category), matches, sev,
      // Gated on provenance, not on length alone: correlateSbom() walks the whole
      // ledger and is uncapped, so a 500-match client-side correlation is a real
      // total and must never be typeset "500+".
      capped: fromServer && matches.length >= MATCH_CAP,
      maxCvss: matches.length ? Math.max(...matches.map(m => m.vuln.cvss)) : 0 };
  }), [components]);

  // Filter options come from the components actually on screen, so a dropdown never
  // offers a type, supplier or OEM that would return nothing.
  // Ordered by the label the dropdown actually shows, not by the raw category id.
  const catOpts = useMemo(() => [...new Set(rows.map(r => r.c.category))].filter(Boolean).
    sort((a, b) => catMeta(a).label.localeCompare(catMeta(b).label)), [rows]);
  const supOpts = useMemo(() => [...new Set(rows.map(r => r.c.supplier))].filter(s => s && s !== "—").sort(), [rows]);
  const brandOpts = useMemo(() => [...new Set(rows.flatMap(r => r.c.brands || []))].filter(Boolean).sort(), [rows]);

  // Drop any selection the data no longer offers. Two of these controls hide
  // themselves when their option list stops being useful (Type below one entry,
  // Supplier below two), so without this a value left over from a previous fetch
  // could filter the grid down to nothing from a dropdown that is no longer on
  // screen to clear it.
  useEffect(() => {
    const prune = (sel, opts, setter) => {
      if (!sel.size) return;
      const keep = new Set([...sel].filter(v => opts.includes(v)));
      if (keep.size !== sel.size) setter(keep);
    };
    prune(catSel, catOpts, setCatSel);
    prune(supSel, supOpts, setSupSel);
    prune(brandSel, brandOpts, setBrandSel);
  }, [catOpts, supOpts, brandOpts]);

  const filtered = useMemo(() => {
    const s = q.trim().toLowerCase();
    const out = rows.filter(r => {
      const c = r.c;
      if (catSel.size && !catSel.has(c.category)) return false;
      if (supSel.size && !supSel.has(c.supplier)) return false;
      if (brandSel.size && !(c.brands || []).some(b => brandSel.has(b))) return false;
      // A severity pick asks "which builds carry at least one of these?", not "which
      // are exactly this bad" — a Critical build still shows under High if it has any.
      if (sevSel.size && !SEV_ORDER.some(k => sevSel.has(k) && r.sev[k] > 0)) return false;
      if (riskF === "affected" && r.matches.length === 0) return false;
      if (riskF === "clean" && r.matches.length > 0) return false;
      if (s) {
        const meta = [c.name, r.cat.label, c.supplier, c.sbomName, c.ecuVersion, (c.brands || []).join(" ")]
          .join(" ").toLowerCase();
        // Search falls through to the SBOM itself and to the matched CVE ids, so
        // "openssl" or "CVE-2024-3094" finds every build that ships the package or
        // carries the finding — the question this page is usually opened to answer.
        // Checked last: the library scan is the only part that walks thousands of rows.
        if (!meta.includes(s)
          && !c.libs.some(l => (l.name || "").toLowerCase().includes(s))
          && !r.matches.some(m => (m.vuln.id || "").toLowerCase().includes(s))) return false;
      }
      return true;
    });
    out.sort((a, b) => {
      if (sort === "risk") return compRiskScore(b.sev) - compRiskScore(a.sev) || b.maxCvss - a.maxCvss;
      // The cap ties every capped card at the same number, so without a tiebreak
      // "Sort: CVE matches" returned a row of 500s in arbitrary server order.
      // maxCvss is useless here — it reads 10.0 on most of them.
      if (sort === "matches") return b.matches.length - a.matches.length
        || compRiskScore(b.sev) - compRiskScore(a.sev)
        || (a.c.name || "").localeCompare(b.c.name || "", undefined, { sensitivity: "base" });
      if (sort === "cvss") return b.maxCvss - a.maxCvss;
      if (sort === "libs") return b.c.libs.length - a.c.libs.length;
      if (sort === "new") {
        // Components with no createdAt (e.g. built client-side, pre-save) sink below
        // every dated one rather than sorting as the epoch.
        const ta = a.c.createdAt ? Date.parse(a.c.createdAt) : NaN;
        const tb = b.c.createdAt ? Date.parse(b.c.createdAt) : NaN;
        return (isNaN(tb) ? -Infinity : tb) - (isNaN(ta) ? -Infinity : ta);
      }
      return (a.c.name || "").localeCompare(b.c.name || "", undefined, { sensitivity: "base" });
    });
    return out;
  }, [rows, q, catSel, supSel, brandSel, sevSel, riskF, sort]);

  const activeFilters = catSel.size + supSel.size + brandSel.size + sevSel.size +
    (riskF !== "all" ? 1 : 0) + (q.trim() ? 1 : 0);
  function clearAll() {
    setQ(""); setCatSel(new Set()); setSupSel(new Set()); setBrandSel(new Set());
    setSevSel(new Set()); setRiskF("all");
  }

  if (loading && (!components || components.length === 0))
    return <PageLoading label="Loading components…" />;
  return (
    <div className="page">
      <div className="page-head">
        <div>
          <span className="eyebrow">Inventory</span>
          <h1 className="page-title">Components</h1>
          {/* One line, in the same length band as the Dashboard/Ledger/Browse
              descriptions. The old two-line version sat directly above a head that
              has since gained a toolbar and a filter bar, and its second sentence
              repeated the empty state below verbatim. */}
          <p className="page-desc">Vehicle components with an SBOM, correlated against the live CVE ledger.</p>
        </div>
        <div className="page-head-aside">
          <button className="btn btn-accent" onClick={onNew}><Icon name="plus" size={15} />New component</button>
        </div>
      </div>

      {components.length === 0 ? (
        <div className="card empty-card">
          <div className="empty">
            <Icon name="package" size={26} />
            <p>No components yet</p>
            <span className="empty-sub">Upload an SBOM and vulnerability.core will correlate each component against the live CVE ledger.</span>
            <button className="btn btn-accent" onClick={onNew}><Icon name="plus" size={15} />New component</button>
          </div>
        </div>
      ) : (
      <>
      {/* Search and filters are one control surface acting on the grid, not two
          peer bands — see .comp-controls in styles/wizard.css. */}
      <div className="comp-controls">
      {/* toolbar */}
      <div className="toolbar">
        <div className="search">
          <Icon name="search" size={16} className="search-ic" />
          <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search component, supplier, OEM, SBOM file, library or CVE…" spellCheck="false" />
          {q && <button className="search-clear" aria-label="Clear search" onClick={() => setQ("")}><Icon name="close" size={14} /></button>}
        </div>
        <div className="tool-spacer" />
        <Dropdown label="Sort" value={COMP_SORTS.find(o => o[0] === sort)[1]} icon="sortDown">
          {(close) => COMP_SORTS.map(([k, l]) =>
          <button key={k} className={"dd-item" + (sort === k ? " is-on" : "")} onClick={() => {setSort(k);close();}}>{l}{sort === k && <Icon name="check" size={14} />}</button>
          )}
        </Dropdown>
      </div>

      {/* filter chips */}
      <div className="filterbar">
        {/* Same guard, and for the same reason, as Supplier below: a filter whose
            option list has one member cannot change the result set. It matters more
            here than it looks — ComponentDataService assigns Category = "ecu"
            unconditionally (it is the only assignment in the service, and the
            wizard's category picker is discarded on save), so today this dropdown
            is always dead and would otherwise sit in the leftmost slot of the bar
            as the only control that does nothing. */}
        {catOpts.length > 1 &&
        <>
          <div className="filterbar-group">
            <span className="filterbar-lab"><Icon name="filter" size={14} />Type</span>
            <Dropdown name="Type" active={catSel.size} value={multiVal(catSel, catOpts, id => catMeta(id).label)} small portal>
              {(close) => (
                <>
                  {catOpts.map(id =>
                    <button key={id} className={"dd-item" + (catSel.has(id) ? " is-on" : "")} onClick={() => toggle(catSel, id, setCatSel)}>
                      <span className="dd-item-l">{catMeta(id).label}</span>{catSel.has(id) && <Icon name="check" size={14} />}
                    </button>
                  )}
                  {catSel.size > 0 && <button className="dd-item dd-item-clear" onClick={() => { setCatSel(new Set()); close(); }}>Clear</button>}
                </>
              )}
            </Dropdown>
          </div>
          <span className="filterbar-div" />
        </>
        }
        <div className="filterbar-group">
          <span className="filterbar-lab">Severity</span>
          <Dropdown name="Severity" active={sevSel.size} value={multiVal(sevSel, SEV_ORDER, s => s)} small portal>
            {(close) => (
              <>
                {SEV_ORDER.map(s =>
                  <button key={s} className={"dd-item" + (sevSel.has(s) ? " is-on" : "")} onClick={() => toggle(sevSel, s, setSevSel)}>
                    <span className="dd-item-l"><span className={"chip-dot " + sevClass(s)} />{s}</span>{sevSel.has(s) && <Icon name="check" size={14} />}
                  </button>
                )}
                {sevSel.size > 0 && <button className="dd-item dd-item-clear" onClick={() => { setSevSel(new Set()); close(); }}>Clear</button>}
              </>
            )}
          </Dropdown>
        </div>
        <span className="filterbar-div" />
        <div className="filterbar-group">
          <span className="filterbar-lab">Risk</span>
          <Dropdown name="Risk" active={riskF !== "all"} value={COMP_RISK_FILTERS.find(o => o[0] === riskF)[1]} small portal>
            {(close) => COMP_RISK_FILTERS.map(([k, l]) =>
            <button key={k} className={"dd-item" + (riskF === k ? " is-on" : "")} onClick={() => {setRiskF(k);close();}}>{l}{riskF === k && <Icon name="check" size={14} />}</button>
            )}
          </Dropdown>
        </div>
        {/* Every component has exactly one supplier, so a single option would filter
            nothing — the OEM list next to it is different: components can carry zero
            brands, so even one option is a real cut. */}
        {supOpts.length > 1 &&
        <>
            <span className="filterbar-div" />
            <div className="filterbar-group">
              <span className="filterbar-lab">Supplier</span>
              <Dropdown name="Supplier" active={supSel.size} value={multiVal(supSel, supOpts, s => s)} small portal>
                {(close) => (
                  <>
                    {supOpts.map(s =>
                      <button key={s} className={"dd-item" + (supSel.has(s) ? " is-on" : "")} onClick={() => toggle(supSel, s, setSupSel)}>
                        <span className="dd-item-l">{s}</span>{supSel.has(s) && <Icon name="check" size={14} />}
                      </button>
                    )}
                    {supSel.size > 0 && <button className="dd-item dd-item-clear" onClick={() => { setSupSel(new Set()); close(); }}>Clear</button>}
                  </>
                )}
              </Dropdown>
            </div>
          </>
        }
        {brandOpts.length > 0 &&
        <>
            <span className="filterbar-div" />
            <div className="filterbar-group">
              <span className="filterbar-lab"><Icon name="layers" size={14} />OEM</span>
              <Dropdown name="OEM" active={brandSel.size} value={multiVal(brandSel, brandOpts, b => b)} small portal>
                {(close) => (
                  <>
                    {brandOpts.map(b =>
                      <button key={b} className={"dd-item" + (brandSel.has(b) ? " is-on" : "")} onClick={() => toggle(brandSel, b, setBrandSel)}>
                        <span className="dd-item-l">{b}</span>{brandSel.has(b) && <Icon name="check" size={14} />}
                      </button>
                    )}
                    {brandSel.size > 0 && <button className="dd-item dd-item-clear" onClick={() => { setBrandSel(new Set()); close(); }}>Clear</button>}
                  </>
                )}
              </Dropdown>
            </div>
          </>
        }
        {activeFilters > 0 && <button className="filterbar-clear" onClick={clearAll}>Clear all ({activeFilters})</button>}
        {/* The count is the reliable live region here — it is always mounted,
            unlike the conditional empty state. */}
        <span className="filterbar-count" role="status">{filtered.length} of {rows.length}</span>
      </div>
      </div>

      {filtered.length === 0 ? (
        <div className="card empty-card">
          <div className="empty">
            <Icon name="search" size={22} />
            <p>No components match these filters.</p>
            <button className="btn btn-ghost btn-sm" onClick={clearAll}>Clear filters</button>
          </div>
        </div>
      ) : (
      <>
      {/* Computed off `filtered`, not `rows`, so the note goes away when the user
          filters down to components the cap never touched. */}
      {filtered.some(r => r.capped) &&
        <p className="comp-capnote">“+” means at least {MATCH_CAP} matches: the API returns only the top {MATCH_CAP} by CVSS, so on those cards the severity split is a slice of the worst findings, not the full distribution.</p>}
      <div className="ent-grid">
        {filtered.map(({ c, cat, matches, sev, maxCvss, capped }) =>
          <button key={c.id} type="button"
            className={"entcard compcard" + (capped ? " is-capped" : "")}
            onClick={() => onOpen(c)}
            aria-label={compCardLabel({ c, cat, matches, sev, maxCvss, capped })}>
            <div className="entcard-top">
              <span className="entcard-avatar"><Icon name={cat.icon} size={18} /></span>
              <div className="entcard-id">
                {/* title=, because the name is clamped to two lines and this page's
                    whole problem is telling ePTO_Application from
                    ePTO_Application_refudrive. */}
                <span className="entcard-name" title={c.name}>{c.name}</span>
                <span className="compcard-idline">
                  {/* Supplier first: every component the server returns is
                      Category "ecu" (it is hardcoded), so leading with the type
                      would spend this ellipsised line on a constant. */}
                  <span className="entcard-meta">{c.supplier && c.supplier !== "—" ? c.supplier + " · " + cat.label : cat.label}</span>
                  {maxCvss > 0 &&
                    <span className="compcard-cvss" title="Highest CVSS of any matched CVE">CVSS {maxCvss.toFixed(1)}</span>}
                </span>
              </div>
              <span className="entcard-arrow"><Icon name="chevR" size={16} /></span>
            </div>
            <div className="entcard-stats">
              {/* is-warn is deliberately gone: it fired on `matches.length ?`, i.e.
                  ANY match, so a build whose worst finding was Medium got an orange
                  headline outranking its own severity mix. The bar and legend own
                  the colour now. */}
              <div className="entcard-stat entcard-stat-hero"
                title={capped
                  ? `Top ${MATCH_CAP} matches by CVSS. This component has at least ${MATCH_CAP}; the API does not report the exact total, so the severity split below is also a top-${MATCH_CAP} slice.`
                  : `${matches.length} matched CVEs`}>
                <b>{matches.length}{capped ? "+" : ""}</b><span>CVE matches</span>
              </div>
              {/* "SBOM entries", not "inventories" and not "libraries": the list is
                  mixed-type (library, application, firmware, operating-system…), and
                  the card was naming the container while counting the contents. */}
              <div className="entcard-stat"><b>{c.libs.length}</b><span>SBOM entries</span></div>
            </div>
            {matches.length > 0 ? (
              <>
                <div className="entcard-bar" aria-hidden="true">
                  {SEV_ORDER.map(s => sev[s] > 0 &&
                    <span key={s} className={"entcard-seg " + sevClass(s)} style={{ flex: sev[s] }} title={`${sev[s]} ${s}`} />
                  )}
                </div>
                {/* All four bands, always, in a fixed order — a rendered "0 Critical"
                    is information, an omitted one is ambiguous. */}
                <div className="entcard-legend">
                  {SEV_ORDER.map(s =>
                    <span key={s} className={"entcard-leg " + sevClass(s) + (sev[s] ? "" : " is-zero")}>
                      <b>{sev[s]}</b><span>{s}</span>
                    </span>
                  )}
                </div>
              </>
            ) : (
              <div className="compcard-clean"><Icon name="check" size={14} />No matching CVEs</div>
            )}
            {/* The SBOM filename that used to sit here is generated server-side from
                the component's own name and version, so it restated the card's title
                in mono in its widest slot. The OEM is the field that actually differs
                between two identically-named builds — and it was being rendered as
                the digit "1". */}
            <div className="compcard-foot">
              <span className="compcard-oem" title={(c.brands || []).join(" · ")}>
                <span className="compcard-foot-lab">OEM</span>
                {(c.brands || []).length ? c.brands.join(" · ") : "—"}
              </span>
              {c.createdAt && <span className="compcard-added">added {c.createdAt}</span>}
            </div>
          </button>
        )}
      </div>
      </>
      )}
      </>
      )}
    </div>
  );
}

/* ------------------------------------------------------ detail list paging */
/* Every pager on the two detail pages sits UNDER its list, so a page turn from down
   there used to leave the reader looking at the next section. Set the page, then jump
   back to the section head, but only once that head has gone under the sticky topbar
   (--topbar-h, read from CSS so the number lives in one place), and instantly: the
   rows swap in place, so an animated scroll would only replay the swap.
   Keyboard: Next is disabled on the last page, so pressing it dropped focus to <body>,
   and the jump carried the view away from whatever control still had it. When the
   turn came from a pager and either happened, focus moves to the section's <h3>
   (tabIndex -1), so the next Tab walks the new rows. A mouse click gets no ring:
   script focus after a pointer event does not match :focus-visible. */
function compTurnPage(sec, setPage, p) {
  const from = document.activeElement;
  const fromPager = !!(from && from.closest && from.closest(".pager"));
  setPage(p);
  if (!sec) return;
  const jumped = sec.getBoundingClientRect().top < (parseFloat(getComputedStyle(sec).getPropertyValue("--topbar-h")) || 0);
  if (jumped) sec.scrollIntoView({ block: "start" });
  if (fromPager) requestAnimationFrame(() => {
    const lost = !document.activeElement || document.activeElement === document.body;
    const head = sec.querySelector(".dsec-head h3");
    if ((jumped || lost) && head) head.focus({ preventScroll: true });
  });
}

/* ------------------------------------------------------ detail risk strip */
/* The headline numbers of a component (or one library in it), under the header on
   both detail pages. It reads the WHOLE match set it is given: never pageMatches,
   never a status-filtered view, because it speaks for the component, not for what
   the list below happens to show. The bar and legend are the list card's own, with
   all four bands always present — a rendered "0 Critical" is information, an
   omitted one is ambiguous. "+" means the server's top-MATCH_CAP slice is full.
   With no matches there is nothing for max CVSS or "inventories affected" to say
   but a bare "—" and a 0, so the strip is the count and the clean line only. */
function CompRiskStrip({ matches, sev, maxCvss, capped, libsTotal, libsAffected, remediatedCount }) {
  const any = matches.length > 0;
  return (
    <section className={"comp-strip" + (capped ? " is-capped" : "")} aria-label="Risk summary">
      <div className="plat-stat comp-stat-hero"
        title={capped ? `Top ${MATCH_CAP} matches by CVSS. This component has at least ${MATCH_CAP}; the API does not report the exact total, so the severity split is also a top-${MATCH_CAP} slice.` : undefined}>
        <b>{matches.length}{capped ? "+" : ""}</b><span>matched CVE{matches.length === 1 ? "" : "s"}</span>
      </div>
      {any && (
        <div className="plat-stat" title="Highest CVSS of any matched CVE">
          <b>{maxCvss.toFixed(1)}</b><span>max CVSS</span>
        </div>
      )}
      {any && libsTotal != null && (
        <div className="plat-stat" title="SBOM inventories with at least one matched CVE">
          <b>{libsAffected}</b><span>of {libsTotal} inventor{libsTotal === 1 ? "y" : "ies"} affected</span>
        </div>
      )}
      <div className="comp-strip-sev">
        {any ? (
          <>
            <div className="entcard-bar" aria-hidden="true">
              {SEV_ORDER.map(s => sev[s] > 0 &&
                <span key={s} className={"entcard-seg " + sevClass(s)} style={{ flex: sev[s] }} />
              )}
            </div>
            <div className="entcard-legend">
              {SEV_ORDER.map(s =>
                <span key={s} className={"entcard-leg" + (sev[s] ? "" : " is-zero")}>
                  <span className={"chip-dot " + sevClass(s)} /><b>{sev[s]}</b>{s}
                </span>
              )}
            </div>
          </>
        ) : (
          /* The headline statement. The list's own empty state below says what is
             left to do rather than repeating this. Worded for either page. */
          <div className="comp-strip-clean">
            <Icon name="check" size={14} />
            No tracked CVEs match{remediatedCount ? ` · ${remediatedCount} remediated via SBOM update` : ""}
          </div>
        )}
      </div>
    </section>
  );
}

function ComponentDetail({ comp, statusOf, deadlineOf, canIssueControlled, canDelete, onBack, onOpenVuln, onOpenLib, onUpdateSbom, onDelete }) {
  const cat = catMeta(comp.category);
  const matches = Array.isArray(comp.matches) ? comp.matches : window.VC.correlateSbom(comp.libs);
  const sev = window.VC.matchSeverity(matches);
  const maxCvss = matches.length ? Math.max(...matches.map(m => m.vuln.cvss)) : 0;
  const st = statusOf || ((v) => v.status);
  const remediated = (comp.remediatedIds || []).map(id => window.VC.VULNS.find(v => v.id === id)).filter(Boolean);

  // ---- Matched-vulnerability pagination -----------------------------------
  // Only the LIST is paged: the risk summary, the severity bar and the exported
  // report all still speak for the whole match set. The triage-status chips above
  // the list narrow the VIEW the same way and nothing else — the risk strip, the
  // severity counts, reportList (so both reports), the delete dialog's counts and
  // the "N found" kicker all read `matches`, never `shown` or `pageMatches`.
  const [page, setPage] = useState(1);
  const [statFilter, setStatFilter] = useState(null);   // null = All
  const statCounts = {};
  matches.forEach(({ vuln }) => { const k = st(vuln); statCounts[k] = (statCounts[k] || 0) + 1; });
  // Chips in the workflow's own order. STATUS_META falls back to a default entry
  // with no sortOrder for a status the registry has never heard of: those go last.
  // "??", not "||": an admin-added status may carry sortOrder 0, and the ledger and
  // the status menus put that one FIRST.
  const statKeys = Object.keys(statCounts).sort((a, b) => (STATUS_META[a].sortOrder ?? 1e9) - (STATUS_META[b].sortOrder ?? 1e9));
  // The 60s status pull can retriage the last row of the chosen status (someone else,
  // another tab). Such a filter reads as All in THIS render, so no frame paints an
  // empty list under a chip that has already gone; the effect below then clears it.
  const statOn = statFilter && statCounts[statFilter] ? statFilter : null;
  const shown = statOn ? matches.filter(({ vuln }) => st(vuln) === statOn) : matches;
  function pickStat(k) { setStatFilter(statOn === k ? null : k); setPage(1); }
  const totalPages = Math.max(1, Math.ceil(shown.length / DETAIL_PAGE_SIZE));
  // Opening a different component must start at its first page with no filter, not
  // wherever the previous one was left; and the page has to be clamped if the set shrinks.
  useEffect(() => { setPage(1); setStatFilter(null); }, [comp.id]);
  useEffect(() => { if (page > totalPages) setPage(totalPages); }, [totalPages, page]);
  useEffect(() => { if (statFilter && !statOn) setStatFilter(null); }, [statFilter, statOn]);
  const pageMatches = useMemo(
    () => shown.slice((page - 1) * DETAIL_PAGE_SIZE, page * DETAIL_PAGE_SIZE),
    [shown, page]);
  // The pager sits under fifteen two-line rows: compTurnPage brings the reader (and a
  // keyboard user's focus) back to the list head. Same for the other two lists below.
  const matchSecRef = useRef(null);
  function goPage(p) { compTurnPage(matchSecRef.current, setPage, p); }

  // Per-library CVE counts for the SBOM inventory badges. Derive them from the
  // authoritative `matches` (the same source as the risk summary + matched list)
  // rather than re-correlating client-side against window.VC.VULNS — the ledger
  // feed's `library_name` is sparse, so a name-based re-count undercounts (e.g.
  // BusyBox's 15 real matches collapse to 1). Key on normalised name + version.
  const libKey = (l) => window.VC.normLib(l.name) + "@" + (l.version || "");
  const sevRank = (s) => { const i = SEV_ORDER.indexOf(s); return i < 0 ? SEV_ORDER.length : i; };
  const cvesByLib = {}, worstByLib = {}, sevByLib = {};
  // One pass over the WHOLE match set, never a page of it. The count drives the sort,
  // the badge and the header strip; the worst band colours the row's rail; the
  // per-band tally names what the badge is counting in the row's tooltip and
  // accessible name, so "15" is not the only thing a hover says.
  matches.forEach(({ lib, vuln }) => {
    const k = libKey(lib);
    cvesByLib[k] = (cvesByLib[k] || 0) + 1;
    const b = sevByLib[k] || (sevByLib[k] = {});
    b[vuln.severity] = (b[vuln.severity] || 0) + 1;
    if (worstByLib[k] == null || sevRank(vuln.severity) < sevRank(worstByLib[k])) worstByLib[k] = vuln.severity;
  });
  // The Supplier column earns its width only when some entry names one. "—" is the
  // server's stand-in for an SBOM that names none, not a supplier, so it does not count.
  const anySupplier = useMemo(() => comp.libs.some(l => l.supplier && l.supplier !== "—"), [comp.libs]);

  // The inventory reads worst-first: the libraries that matched CVEs on top, most
  // matches first, then the clean ones — alphabetical inside each group so a
  // several-thousand-row SBOM is still scannable. The server returns these in
  // insertion order, which puts nothing useful at the top of a list that long.
  // Sorted on a COPY: comp.libs is the same array the risk summary and the Update
  // SBOM modal read, and neither expects it reordered underneath them.
  const sortedLibs = useMemo(
    () => comp.libs.slice().sort((a, b) =>
      (cvesByLib[libKey(b)] || 0) - (cvesByLib[libKey(a)] || 0) ||
      (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" })),
    [comp.libs, matches]);

  // Same treatment as the matched list, on its own page counter so paging one does
  // not move the other. The name filter narrows the sorted copy (so it never
  // reorders it) and sends the pager back to page 1 whenever the query changes; a
  // different component starts unfiltered.
  const [libPage, setLibPage] = useState(1);
  const [libQ, setLibQ] = useState("");
  useEffect(() => { setLibQ(""); }, [comp.id]);
  useEffect(() => { setLibPage(1); }, [libQ]);
  const shownLibs = useMemo(() => {
    const q = libQ.trim().toLowerCase();
    return q ? sortedLibs.filter(l => ((l.name || "") + "@" + (l.version || "")).toLowerCase().includes(q)) : sortedLibs;
  }, [sortedLibs, libQ]);
  const libTotalPages = Math.max(1, Math.ceil(shownLibs.length / DETAIL_PAGE_SIZE));
  useEffect(() => { setLibPage(1); }, [comp.id]);
  useEffect(() => { if (libPage > libTotalPages) setLibPage(libTotalPages); }, [libTotalPages, libPage]);
  const pageLibs = useMemo(
    () => shownLibs.slice((libPage - 1) * DETAIL_PAGE_SIZE, libPage * DETAIL_PAGE_SIZE),
    [shownLibs, libPage]);
  const invSecRef = useRef(null);
  function goLibPage(p) { compTurnPage(invSecRef.current, setLibPage, p); }
  // Clearing the filter unmounts the clear button, which dropped keyboard focus to
  // <body>; it goes back to the field instead.
  const invInputRef = useRef(null);
  function clearLibQ() { setLibQ(""); if (invInputRef.current) invInputRef.current.focus(); }

  // Remediated gets the same treatment as the other two lists: one SBOM update can
  // resolve dozens, and the list only ever grows.
  const remSecRef = useRef(null);
  const [remPage, setRemPage] = useState(1);
  const remTotalPages = Math.max(1, Math.ceil(remediated.length / DETAIL_PAGE_SIZE));
  useEffect(() => { setRemPage(1); }, [comp.id]);
  useEffect(() => { if (remPage > remTotalPages) setRemPage(remTotalPages); }, [remTotalPages, remPage]);
  const pageRemediated = remediated.slice((remPage - 1) * DETAIL_PAGE_SIZE, remPage * DETAIL_PAGE_SIZE);
  function goRemPage(p) { compTurnPage(remSecRef.current, setRemPage, p); }

  // Styled HTML report scoped to this component's SBOM matches (the same masthead
  // design used by the ledger/dashboard reports). Two buttons, two documents:
  //
  //   Quick report           -> downloadQuick() below. Straight to the file, no
  //                             dialog. `omitDocControl` drops the cover's
  //                             document-control block entirely, and `controlled`
  //                             stays false so the Report Details sheet names no
  //                             author, reviewer or approver.
  //   Version control report -> superadmin OR the company's own admin. The shared
  //                             export dialog collects the document-control details
  //                             (status, version, description, logo, author /
  //                             reviewer / approver) and makes the VCExport.report()
  //                             call itself, issuing a CONTROLLED copy with the
  //                             Revision History card. The dialog's sign-off fields
  //                             start empty, so whoever issues it signs their own
  //                             name to it.
  const [reportOpen, setReportOpen] = useState(false);
  const reportList = useMemo(() => matches.map(({ vuln, lib }) => ({
    ...vuln,
    library_name: lib.name + (lib.version ? "@" + lib.version : ""),
  })), [matches]);

  const reportOpts = {
    target: comp.name + " · " + comp.supplier + " · " + comp.sbomName,
    // The report's "ECU Function / Domain" row names the unit under test. Only this
    // screen knows it: the name typed at step 1 of the wizard. Without it reportHtml
    // falls back to the per-finding v.ecu values, which collapse into an unreadable
    // "board.cgi, libIEC61850, ETH-250 (+180 more)".
    componentName: comp.name,
    statusOf: st,
    deadlineOf,
    filename: (comp.name || "component").toLowerCase().replace(/[^a-z0-9]+/g, "-") + "-report",
  };

  // Quick report: no dialog, straight to the file.
  //
  // The flags MUST travel under `doc`. reportHtml builds its config as
  //   Object.assign({}, LAB_DOC, {issueDate}, readDocSettings(), opts.doc || {})
  // so anything set at the top level of opts never reaches it.
  //
  //   omitDocControl -> drops the cover's document-control block outright: no
  //                     Revision History card, and no "uncontrolled copy" notice
  //                     in its place either.
  //   controlled     -> forced false, not merely left to default. readDocSettings()
  //                     is per-browser and may already hold controlled:true from a
  //                     previously issued copy; without this the Quick report would
  //                     inherit it and sign the Report Details sheet with the
  //                     author / reviewer / approver of a revision nobody approved.
  //                     (The Doc No / Issue No footer strip is NOT gated on this —
  //                     it identifies the lab's form and prints on every page of
  //                     every copy.)
  //
  // Everything else — findings, layout, masthead, detail sheets — is identical to
  // the Version control report.
  function downloadQuick() {
    window.VCExport.report(reportList, {
      ...reportOpts,
      doc: { omitDocControl: true, controlled: false },
    });
  }

  // Deleting is irreversible and there is no soft-delete behind it, so it is confirmed
  // in a dialog that spells out what goes with the component rather than in a title
  // attribute nobody reads. `deleting` also disables the button, because the request
  // ends with a list refetch and a double-click would fire the second one at an id the
  // server has already forgotten. The dialog is Administration's DeleteConfirm, for
  // its portal (rendered inside .page, the old scrim painted UNDER the sticky topbar),
  // its focus on Cancel and its Escape handling. admin.jsx loads AFTER this file, so
  // the name resolves only at render time: never call it while this file loads.
  // The SBOM file this build was created from, exactly as it was uploaded or generated.
  // Builds created before documents were kept have none, and neither has an inventory
  // edited by hand in the Update SBOM dialog: no file describes it. Every build can
  // still be downloaded in each format, written from its inventory.
  const sbomDoc = comp.document || null;
  const hasOriginal = !!(sbomDoc && sbomDoc.contentBytes);
  const ecuRows = sbomDoc ? [
    ["Hardware part no.", sbomDoc.hwPartNumber], ["Software part no.", sbomDoc.swPartNumber],
    ["Microcontroller", sbomDoc.mcu], ["Variant", sbomDoc.variantId],
  ].filter(([, v]) => v) : [];
  const sbomOptions = useMemo(() => {
    const get = (format) => () => window.VCApi.componentSbomFile(comp.id, format);
    const options = [];
    if (hasOriginal)
      options.push({ id: "original", label: "Original file", hint: (sbomDoc.fileName || "The SBOM") + ", exactly as uploaded or generated", get: get(null) });
    options.push(
      { id: "cyclonedx", label: "CycloneDX 1.6 (JSON)", hint: "This build's inventory, as monitored", get: get("cyclonedx") },
      { id: "spdx", label: "SPDX 2.3 (JSON)", hint: "The same inventory, for tools that ask for SPDX", get: get("spdx") },
      { id: "csv", label: "CSV component list", hint: "Opens in a spreadsheet, and can be uploaded again", get: get("csv") });
    return options;
  }, [comp.id, hasOriginal, sbomDoc && sbomDoc.fileName]);

  const [confirmDelete, setConfirmDelete] = useState(false);
  const [deleting, setDeleting] = useState(false);
  async function reallyDelete() {
    setDeleting(true);
    try { await onDelete(comp); }        // owns the toast, the refetch and the navigation
    finally { setDeleting(false); setConfirmDelete(false); }
  }

  // Same provenance rule as the list card: only the server's set is capped, a
  // client-side correlation is a real total and never earns the "+".
  const capped = Array.isArray(comp.matches) && matches.length >= MATCH_CAP;
  // Memoised like sortedLibs (same inputs): the name filter and the status chips
  // re-render on every keystroke and click, and a big SBOM holds thousands of entries.
  const libsAffected = useMemo(() => comp.libs.filter(l => cvesByLib[libKey(l)]).length, [comp.libs, matches]);
  // The list card's own guard: the API sends "—" when the SBOM names no supplier.
  const hasSupplier = comp.supplier && comp.supplier !== "—";
  // Component and library names are usually snake_case identifiers with no spaces, so
  // a long one had nowhere to break but mid-word. <wbr> offers a break after each "_"
  // (a name without one is untouched) and adds no text, so textContent is unchanged.
  const wbrUnderscores = (s) => (s || "").split("_").map((part, i, all) =>
    i < all.length - 1 ? <React.Fragment key={i}>{part}_<wbr /></React.Fragment> : part);

  return (
    <div className="page detail comp-detail">
      <button className="back" onClick={onBack}><Icon name="arrowL" size={16} />Components</button>

      <div className="detail-head-main comp-head">
        <div className="detail-id-row">
          <span className="kindtag kindtag-auto"><Icon name={cat.icon} size={12} />{cat.label}</span>
          <span className="detail-src">{comp.spec} · added {comp.createdAt}</span>
          {/* Its own span so a phone can give it a line of its own (wizard.css), where
              the leading "·" would dangle and is hidden; on a wider screen the "·"
              keeps the line reading as one list instead of a run-on. */}
          {comp.lastUpdated && <span className="detail-src comp-src-upd"><span className="comp-src-dot">· </span>updated {comp.lastUpdated}</span>}
        </div>
        <h1 className="detail-id">{wbrUnderscores(comp.name)}</h1>
        {/* Keyed tags: a bare value with an icon left "CyMobility" to be guessed as
            the supplier or the OEM. No supplier means no tag, not a tag reading "—". */}
        <div className="detail-tags">
          {hasSupplier && <span className="dtag" title={"Supplier: " + comp.supplier}><span className="comp-tag-k">Supplier</span><span className="comp-tag-v">{comp.supplier}</span></span>}
          {comp.brands.length > 0 && <span className="dtag" title={"OEM: " + comp.brands.join(" · ")}><span className="comp-tag-k">OEM</span><span className="comp-tag-v">{comp.brands.join(" · ")}</span></span>}
          <span className="dtag" title={comp.sbomName}><Icon name="doc" size={13} /><span className="comp-tag-v">{comp.sbomName}</span></span>
        </div>
      </div>
      <CompRiskStrip matches={matches} sev={sev} maxCvss={maxCvss} capped={capped}
        libsTotal={comp.libs.length} libsAffected={libsAffected} remediatedCount={remediated.length} />

      <div className="detail-grid">
        <div className="detail-main">
          <section className="dsec mvl-sec" ref={matchSecRef}>
            <div className="dsec-head">
              <span className="dsec-ic"><Icon name="sparkle" size={16} /></span>
              {/* tabIndex -1: compTurnPage lands keyboard focus here after a page turn. */}
              <h3 tabIndex={-1}>Matched vulnerabilities</h3>
              {/* The size of the WHOLE set, with the strip's "+" when the server's
                  slice is full. The page number is the pager's job; it used to be
                  repeated here as "· page 1/2". Outside the aside so that, on a narrow
                  card, the buttons fold to their own line and the count stays beside
                  the title. */}
              <span className="dsec-kick">{matches.length}{capped ? "+" : ""} found</span>
              <div className="dsec-head-aside mvl-actions">
                {/* Two ways out of this screen, and they are different DOCUMENTS.

                    Quick report  -> straight to the file. Same findings, same layout,
                                     no document-control block on the cover at all.
                                     For someone who wants the technical content now.
                    Version control report -> the export dialog, which collects the
                                     revision status, version, change description and
                                     the author / reviewer / approver, and issues a
                                     CONTROLLED copy carrying the Revision History card
                                     and the sign-off names on the Report Details sheet.

                    The second is gated on canIssueControlled because those fields
                    assert that a named person reviewed and approved this revision.
                    Issuing one is the compliance owner's job — the platform admin, or
                    the customer's own company admin. An analyst still gets the
                    findings via Quick report; what they must not do is hand out a
                    document that claims a review nobody performed. */}
                <button className="btn btn-sm" onClick={downloadQuick}
                  title="Download now — no revision history or sign-off block">
                  <Icon name="download" size={14} />Quick report
                </button>
                {canIssueControlled && (
                  <button className="btn btn-sm btn-accent" onClick={() => setReportOpen(true)}
                    title="Issue a controlled copy with a Revision History and sign-off">
                    <Icon name="doc" size={14} />Version control report
                  </button>
                )}
              </div>
            </div>
            {/* The strip above already says "no tracked CVEs match"; this says what
                that leaves to do, and where the CVEs an SBOM update cleared went. */}
            {matches.length === 0 ? (
              <div className="empty"><Icon name="check" size={22} /><p>{remediated.length
                ? `Nothing to triage. The ${remediated.length} CVE${remediated.length === 1 ? "" : "s"} an earlier SBOM matched ${remediated.length === 1 ? "is" : "are"} listed under Remediated below.`
                : "Nothing to triage. New CVEs that match this SBOM will be listed here."}</p></div>
            ) : (
              <>
                {/* Only worth offering when the set holds more than one status: a
                    lone chip would filter to what is already on screen. */}
                {statKeys.length > 1 && (
                  <div className="mvl-filters" role="group" aria-label="Filter by triage status">
                    <button type="button" className={"admin-filter" + (!statOn ? " is-on" : "")} aria-pressed={!statOn}
                      onClick={() => { setStatFilter(null); setPage(1); }}>
                      All<span className="admin-filter-n">{matches.length}{capped ? "+" : ""}</span>
                    </button>
                    {statKeys.map(k => (
                      <button type="button" key={k} className={"admin-filter" + (statOn === k ? " is-on" : "")} aria-pressed={statOn === k}
                        onClick={() => pickStat(k)}>
                        <span className="statuspill-dot" style={{ background: STATUS_META[k].dot }} />
                        {STATUS_META[k].label}<span className="admin-filter-n">{statCounts[k]}</span>
                      </button>
                    ))}
                  </div>
                )}
                {/* aria-pressed tells a screen reader which chip is on, but not that
                    the list under it changed; this polite region does. */}
                <span className="vc-sr-only" role="status">{statOn
                  ? `${shown.length} of ${matches.length} shown: ${STATUS_META[statOn].label}`
                  : `All ${matches.length} shown`}</span>

                {/* Columns come from wizard.css (.mvl-*), not from the row itself, so
                    the "via" text and the status pills line up down the whole list. */}
                <div className="ent-list mvl-list">
                  {pageMatches.map(({ vuln, lib }) => {
                    const s = st(vuln);
                    // No "@" when the SBOM gave no version: "via FreeRTOS", not "via FreeRTOS@".
                    const libLabel = lib.name + (lib.version ? "@" + lib.version : "");
                    return (
                      <button type="button" className="ent-row mvl-row" key={vuln.id} onClick={() => onOpenVuln(vuln)}>
                        <SeverityTag s={vuln.severity} cvss={vuln.cvss} size="sm" />
                        <span className="ent-row-id mono">{vuln.id}</span>
                        <span className="ent-row-desc" title={vuln.description}>{vuln.description}</span>
                        <span className="ent-row-comp mono mvl-via" title={libLabel}>via {libLabel}</span>
                        <span className="mvl-stat" title={STATUS_META[s].label}><StatusPill status={s} /></span>
                        <Icon name="chevR" size={15} className="ent-row-chev" />
                      </button>
                    );
                  })}
                </div>

                <Pager page={page} total={shown.length} pageSize={DETAIL_PAGE_SIZE} onPage={goPage} label="Matched vulnerabilities pages" />
              </>
            )}
          </section>

          {remediated.length > 0 && (
            <section className="dsec mvl-sec" ref={remSecRef}>
              <div className="dsec-head">
                <span className="dsec-ic dsec-ic-ok"><Icon name="check" size={16} /></span>
                <h3 tabIndex={-1}>Remediated via SBOM update</h3>
                <span className="dsec-kick">{remediated.length} resolved</span>
              </div>
              {/* The matched list's row grid (.mvl-*) verbatim, so a CVE that an SBOM
                  update moves from that list into this one keeps its columns. */}
              <div className="ent-list mvl-list">
                {pageRemediated.map(v => (
                  <button type="button" className="ent-row ent-row-rem mvl-row" key={v.id} onClick={() => onOpenVuln(v)}>
                    <SeverityTag s={v.severity} cvss={v.cvss} size="sm" />
                    <span className="ent-row-id mono">{v.id}</span>
                    <span className="ent-row-desc" title={v.description}>{v.description}</span>
                    <span className="ent-row-comp mvl-via"><Icon name="check" size={12} />fixed in current SBOM</span>
                    <span className="mvl-stat"><StatusPill status="remediated" /></span>
                    <Icon name="chevR" size={15} className="ent-row-chev" />
                  </button>
                ))}
              </div>
              <Pager page={remPage} total={remediated.length} pageSize={DETAIL_PAGE_SIZE} onPage={goRemPage} label="Remediated pages" />
            </section>
          )}

          <section className="dsec compinv-sec" ref={invSecRef}>
            <div className="dsec-head">
              <span className="dsec-ic"><Icon name="doc" size={16} /></span>
              <h3 tabIndex={-1}>SBOM inventory</h3>
              {/* What the list holds, not which page: the pager under it says that.
                  Outside the aside, as in the matched head, so both heads fold the
                  same way on a narrow card: count beside the title, button below. */}
              <span className="dsec-kick">{libQ.trim()
                ? `${shownLibs.length} of ${comp.libs.length} inventories`
                : `${comp.libs.length} inventor${comp.libs.length === 1 ? "y" : "ies"}`}</span>
              <div className="dsec-head-aside compinv-actions">
                <button className="btn btn-sm" onClick={() => onUpdateSbom(comp)}><Icon name="refresh" size={14} />Update SBOM</button>
              </div>
            </div>
            {comp.libs.length === 0 ? (
              <div className="empty"><Icon name="doc" size={22} /><p>This SBOM lists no inventories.</p></div>
            ) : (
              <>
                {/* Only once the list pages: under 16 entries every name is already on
                    screen. Same search box as the SBOM editor's filter. */}
                {comp.libs.length > DETAIL_PAGE_SIZE && (
                  <div className="compinv-bar">
                    <div className="search compinv-search">
                      <Icon name="search" size={15} className="search-ic" />
                      <input ref={invInputRef} value={libQ} onChange={e => setLibQ(e.target.value)} placeholder="Filter inventories…"
                        aria-label="Filter SBOM inventories by name or version"
                        onKeyDown={e => { if (e.key === "Escape" && libQ) { e.preventDefault(); setLibQ(""); } }} />
                      {libQ && (
                        <button type="button" className="search-clear" aria-label="Clear filter" onClick={clearLibQ}>
                          <Icon name="close" size={14} />
                        </button>
                      )}
                    </div>
                  </div>
                )}
                {shownLibs.length === 0 ? (
                  <div className="compinv-none">No inventory matches “{libQ.trim()}”.</div>
                ) : (
                  <div className={"liblist liblist-flat compinv" + (anySupplier ? "" : " compinv-nosup")}>
                    <div className="compinv-head" aria-hidden="true">
                      {/* "CVE", not "CVEs": the header is uppercased, and "CVES" reads as a word. */}
                      <span>Inventory</span>{anySupplier && <span>Supplier</span>}<span>Type</span><span>CVE</span><span />
                    </div>
                    {pageLibs.map((l, i) => {
                      const k = libKey(l), cves = cvesByLib[k] || 0, worst = worstByLib[k];
                      // "—" is the server's empty supplier; the cell stays (it holds the
                      // column) but says nothing rather than a bare dash.
                      const sup = l.supplier && l.supplier !== "—" ? l.supplier : "";
                      const breakdown = cves ? SEV_ORDER.filter(s => sevByLib[k][s]).map(s => sevByLib[k][s] + " " + s).join(" · ") : "";
                      const body = (
                        <>
                          {/* Name and version flow as one run of text over at most two
                              lines, breaking after an "_" first: in an AUTOSAR SBOM dozens
                              of modules share a long prefix, so the tail is what tells two
                              rows apart and must not be the part that is cut. */}
                          <span className="librow-name mono" title={l.name + (l.version ? " @" + l.version : "")}>
                            {wbrUnderscores(l.name)}
                            {l.version ? <span className="librow-ver"> @{l.version}</span> : null}
                          </span>
                          <span className="compinv-meta">
                            {anySupplier && <span className="librow-sup">{sup}</span>}
                            {l.type ? <span className="librow-type">{l.type}</span> : <span />}
                          </span>
                          {cves > 0
                            ? <span className="librow-cve"><Icon name="alert" size={12} />{cves}</span>
                            : <span className="librow-ok" role="img" aria-label="No matched CVEs" title="No tracked CVEs match this inventory"><Icon name="check" size={14} /></span>}
                        </>
                      );
                      // Only a library that actually matched something opens a page — a
                      // clean row has nothing to show, so it stays inert rather than
                      // offering a click that lands on an empty list.
                      return cves > 0 ? (
                        <button type="button" className={"librow is-flagged " + sevClass(worst)} key={i} onClick={() => onOpenLib(l)}
                          title={"Open the " + cves + " CVE" + (cves === 1 ? "" : "s") + " matched to " + l.name + " — " + breakdown}
                          aria-label={l.name + (l.version ? " " + l.version : "") + ": " + cves + " matched CVE" + (cves === 1 ? "" : "s") + ", " + breakdown}>
                          {body}
                          <Icon name="chevR" size={15} className="librow-chev" />
                        </button>
                      ) : (
                        <div className="librow" key={i}>{body}</div>
                      );
                    })}
                  </div>
                )}
                <Pager page={libPage} total={shownLibs.length} pageSize={DETAIL_PAGE_SIZE} onPage={goLibPage} label="SBOM inventory pages" />
              </>
            )}
          </section>

          {/* Shared team discussion on this component — server-side, so it is the
              same thread for every user in the organisation, on every device.

              Hidden entirely when a superadmin has switched comments off for this company.
              FEATURES.comments is read at render time from the registry /api/config
              fills in, so it is already correct on the first paint of this screen.
              Hiding it is not the enforcement: the server refuses a post regardless,
              which is what covers a session whose cached config predates the change. */}
          <CommentsSection componentId={comp.id} off={!FEATURES.comments} />
        </div>

        <aside className="detail-side" aria-label="Component summary">
          {/* Super admin only, and the server enforces it too — this is not a hidden
              button. Deleting reaches across tenants (a component id names its company
              implicitly), which is why it stops at the one role whose scope already
              does.

              It sits at the top of the side rail rather than in the page header: level
              with the title it was the first thing the eye landed on, and a mis-click
              there costs an SBOM. Here it reads as one of this component's controls
              instead of as a headline. Outlined rather than filled, too — a destructive
              action has to be findable without competing with the page's own data for
              attention, so it only commits to red once the pointer is deliberately on it. */}
          {canDelete && (
            <div className="side-actions">
              <button className="btn btn-sm btn-danger-outline btn-block"
                onClick={() => setConfirmDelete(true)}
                title="Permanently delete this component and everything filed against it">
                <Icon name="trash" size={14} />Delete component
              </button>
            </div>
          )}

          {/* No Risk summary card here any more: it is the strip under the header,
              because at 1180px and below this rail stacks under the main column and
              took the page's headline numbers to the very bottom with it.

              Attribution is a label/value list rather than .metaline rows, so an
              empty value can say so ("Not specified") instead of printing the API's
              bare "—", and OEM is a row like the others rather than a second
              uppercase eyebrow. */}
          <section className="dsec side-card">
            <h3 className="side-title">Attribution</h3>
            <dl className="comp-attr-list">
              <div className="comp-attr-row"><dt>Supplier</dt><dd className={hasSupplier ? undefined : "is-empty"}>{hasSupplier ? comp.supplier : "Not specified"}</dd></div>
              <div className="comp-attr-row"><dt>Category</dt><dd>{cat.label}</dd></div>
              <div className="comp-attr-row"><dt>SBOM format</dt><dd>{comp.spec}</dd></div>
              {/* The header tag ellipsises the file name and only a hover tooltip has
                  it whole, which touch and keyboard never reach. Here it wraps. */}
              <div className="comp-attr-row"><dt>SBOM file</dt><dd className="mono">{comp.sbomName}</dd></div>
              {sbomDoc && sbomDoc.tools && sbomDoc.tools.length > 0 && (
                <div className="comp-attr-row"><dt>Made by</dt><dd>{sbomDoc.tools.join(", ")}</dd></div>
              )}
              {ecuRows.map(([k, v]) => (
                <div className="comp-attr-row" key={k}><dt>{k}</dt><dd className="mono">{v}</dd></div>
              ))}
              <div className="comp-attr-row comp-attr-oem"><dt>{comp.brands.length > 1 ? "OEMs" : "OEM"}</dt><dd className={comp.brands.length ? undefined : "is-empty"}>{comp.brands.length
                ? <span className="reglist">{comp.brands.map(b => <span className="reg" key={b}>{b}</span>)}</span>
                : "None"}</dd></div>
            </dl>
            <div className="comp-sbom-dl">
              <SbomDownloadMenu block options={sbomOptions} />
            </div>
          </section>

          {/* Uploaded supporting documents for this component — the artefacts an
              audit asks for against the build, not against each matched CVE. */}
          <EvidenceCard componentId={comp.id} />

        </aside>
      </div>

      {canIssueControlled && reportOpen && (
        <ReportExportModal
          list={reportList}
          reportOpts={reportOpts}
          onClose={() => setReportOpen(false)} />
      )}

      {canDelete && confirmDelete && (
        <DeleteConfirm eyebrow="Delete component" title={comp.name} busy={deleting}
          onCancel={() => { if (!deleting) setConfirmDelete(false); }}
          onConfirm={reallyDelete}>
          <p>This permanently removes the component and everything filed against it:</p>
          <ul className="del-list">
            <li><b>{comp.libs.length}</b> SBOM {comp.libs.length === 1 ? "inventory" : "inventories"}</li>
            <li><b>{matches.length}</b> matched {matches.length === 1 ? "vulnerability" : "vulnerabilities"}</li>
            <li>its comment thread and any evidence files</li>
          </ul>
          <p className="card-sub">
            Triage status, owners and deadlines are kept — those belong to the
            vulnerability, not to this component, and other components may still
            match the same CVEs.
          </p>
          {/* "There is no undo." is DeleteConfirm's own last line, so it is not repeated here. */}
          <p className="card-sub">Re-uploading the same SBOM creates a new component.</p>
        </DeleteConfirm>
      )}
    </div>
  );
}

/* ---------------------------------------------------------- library detail */
/* One library inside a component's SBOM, and only the CVEs that matched IT.
   Reached by clicking a flagged row in the SBOM inventory — that list can run to
   thousands of rows, so "which CVEs does this one library account for?" is not a
   question the component page can answer without this page.

   `comp` stays in scope: the matches are read from the component's own precomputed
   set (the same source as its Matched vulnerabilities list, so the count agrees with
   the badge that was clicked), and Back returns to the component it came from. */
function LibraryDetail({ comp, lib, statusOf, onBack, onOpenVuln }) {
  const st = statusOf || ((v) => v.status);
  const all = Array.isArray(comp.matches) ? comp.matches : window.VC.correlateSbom(comp.libs);
  // The same key the inventory badge counts on: normalised name + version, so the
  // page opened from a row shows exactly the number that row displayed.
  const key = (l) => window.VC.normLib(l.name) + "@" + (l.version || "");
  const matches = useMemo(() => all.filter((m) => key(m.lib) === key(lib)), [all, lib]);
  const sev = window.VC.matchSeverity(matches);
  const maxCvss = matches.length ? Math.max(...matches.map((m) => m.vuln.cvss)) : 0;
  // "+" on the strip's count: the component's server set is the top MATCH_CAP by
  // CVSS, so once IT is full, this library's slice of it may be short too.
  const capped = Array.isArray(comp.matches) && all.length >= MATCH_CAP;
  // A bare `lib.supplier &&` is always true: data_components.jsx stores "—" for none.
  const libHasSupplier = lib.supplier && lib.supplier !== "—";

  const [page, setPage] = useState(1);
  const totalPages = Math.max(1, Math.ceil(matches.length / DETAIL_PAGE_SIZE));
  useEffect(() => { setPage(1); }, [comp.id, lib.name, lib.version]);
  useEffect(() => { if (page > totalPages) setPage(totalPages); }, [totalPages, page]);
  const pageMatches = useMemo(
    () => matches.slice((page - 1) * DETAIL_PAGE_SIZE, page * DETAIL_PAGE_SIZE),
    [matches, page]);
  // Same as ComponentDetail: paging from below returns to the list head.
  const matchSecRef = useRef(null);
  function goPage(p) { compTurnPage(matchSecRef.current, setPage, p); }

  return (
    <div className="page detail comp-detail">
      <button className="back" onClick={onBack}><Icon name="arrowL" size={16} />{comp.name}</button>

      <div className="detail-head-main comp-head">
        <div className="detail-id-row">
          <span className="kindtag kindtag-auto"><Icon name="package" size={12} />{lib.type || "library"}</span>
          <span className="detail-src">from {comp.sbomName}</span>
        </div>
        <h1 className="detail-id">{lib.name}{lib.version ? <span className="librow-ver"> @{lib.version}</span> : null}</h1>
        <div className="detail-tags">
          {libHasSupplier && <span className="dtag" title={"Supplier: " + lib.supplier}><span className="comp-tag-k">Supplier</span><span className="comp-tag-v">{lib.supplier}</span></span>}
          <span className="dtag" title={comp.name}><Icon name="chip" size={13} /><span className="comp-tag-v">{comp.name}</span></span>
          {lib.purl && <span className="dtag mono" title={lib.purl}><Icon name="doc" size={13} /><span className="comp-tag-v">{lib.purl}</span></span>}
        </div>
      </div>
      <CompRiskStrip matches={matches} sev={sev} maxCvss={maxCvss} capped={capped} />

      <div className="detail-grid">
        <div className="detail-main">
          <section className="dsec mvl-sec" ref={matchSecRef}>
            <div className="dsec-head">
              <span className="dsec-ic"><Icon name="sparkle" size={16} /></span>
              <h3 tabIndex={-1}>Matched vulnerabilities</h3>
              <span className="dsec-kick">{matches.length}{capped ? "+" : ""} found</span>
            </div>
            {matches.length === 0 ? (
              <div className="empty"><Icon name="check" size={22} /><p>Nothing to triage for this library.</p></div>
            ) : (
              <>
                {/* The component page's row grid minus the "via" column: every row
                    here is the same library, so naming it fifteen times says nothing. */}
                <div className="ent-list mvl-list mvl-novia">
                  {pageMatches.map(({ vuln }) => {
                    const s = st(vuln);
                    return (
                      <button type="button" className="ent-row mvl-row" key={vuln.id} onClick={() => onOpenVuln(vuln)}>
                        <SeverityTag s={vuln.severity} cvss={vuln.cvss} size="sm" />
                        <span className="ent-row-id mono">{vuln.id}</span>
                        <span className="ent-row-desc" title={vuln.description}>{vuln.description}</span>
                        <span className="mvl-stat" title={STATUS_META[s].label}><StatusPill status={s} /></span>
                        <Icon name="chevR" size={15} className="ent-row-chev" />
                      </button>
                    );
                  })}
                </div>

                <Pager page={page} total={matches.length} pageSize={DETAIL_PAGE_SIZE} onPage={goPage} label="Matched vulnerabilities pages" />
              </>
            )}
          </section>
        </div>

        {/* The risk numbers moved up into the strip under the header, so the rail
            is only the inventory entry itself. */}
        <aside className="detail-side" aria-label="Library summary">
          <section className="dsec side-card">
            <h3 className="side-title">Inventory entry</h3>
            <dl className="comp-attr-list">
              <div className="comp-attr-row"><dt>Library</dt><dd className="mono">{lib.name}</dd></div>
              <div className="comp-attr-row"><dt>Version</dt>{lib.version && lib.version !== "—"
                ? <dd className="mono">{lib.version}</dd>
                : <dd className="is-empty">Not specified</dd>}</div>
              <div className="comp-attr-row"><dt>Type</dt><dd>{lib.type || "library"}</dd></div>
              <div className="comp-attr-row"><dt>Supplier</dt><dd className={libHasSupplier ? undefined : "is-empty"}>{libHasSupplier ? lib.supplier : "Not specified"}</dd></div>
              <div className="comp-attr-row"><dt>Component</dt><dd>{comp.name}</dd></div>
            </dl>
          </section>
        </aside>
      </div>
    </div>
  );
}

Object.assign(window, { ComponentsScreen, ComponentDetail, LibraryDetail });
