// ===========================================================
// CollectionDNA: a taste-fingerprint panel at the top of the
// collection page, built as a three-stage milestone ladder driven
// entirely by the owner's record count. Four visual groups:
//   1. Progress ladder (always visible): FINGERPRINT / THE READ /
//      DEEP READ nodes on an accent line that fills per record.
//   2. The Fingerprint: four stat cards (ERA, GENRE, SPAN, THE HUNDRED).
//   3. The Read: one dark bubble with a one-paragraph personality read
//      from Claude Haiku. Shows a "ready" CTA until opened, then the
//      paragraph. Opening is session-only: each load returns to the CTA so the
//      panel never stays permanently expanded (profiles.dna_read_opened is still
//      recorded once for analytics, but no longer drives the displayed state).
//   4. The Deep Read: three stat cards (VALUE SHAPE, CONDITION, MOST VALUABLE).
//
// Milestone states, gated on owned-record count:
//   0-19   entire content blurred behind one lock, ladder visible above
//   20-34  Fingerprint live; The Read + Deep Read blurred and locked
//   35-49  Fingerprint + The Read live; Deep Read blurred and locked
//   50+    everything unlocked, all three ladder nodes checked
//
// Every figure traces to real collection data. Cards degrade to honest
// empty states rather than showing a placeholder number. Geography and
// obscurity dimensions were cut earlier (no country column, no honest
// non-canon obscurity signal). Canon position is a SILENT input to the
// Haiku read only and is never shown as a number. The Anthropic key stays
// server-side: this only POSTs the computed dimensions to /api/identify.
// ===========================================================
const { useState: useDnaState, useEffect: useDnaEffect, useMemo: useDnaMemo, useRef: useDnaRef } = React;

// Milestone thresholds live here once. No scattered magic numbers.
const DNA_FINGERPRINT_MIN = 20;   // the Fingerprint cards unlock here
const DNA_READ_MIN = 35;          // The Read (and the Haiku generation) unlock here
const DNA_DEEP_MIN = 50;          // The Deep Read cards unlock here
const DNA_REGEN_DELTA = 10;       // regenerate the read after this many net new records
const DNA_COVERAGE_MIN = 0.6;     // a dimension below this field-coverage is omitted, not faked

// Discogs primary genres arrive raw: stray newlines and casing collisions
// ("Hip-Hop" vs "Hip Hop", "Punk / \n Indie"). Collapse whitespace so the
// same genre groups together; keep the cleaned label for display.
function dnaCleanGenre(g) {
  return String(g || "").replace(/\s+/g, " ").trim();
}

function dnaDecadeLabel(decade) {
  return decade + "s";
}

// Goldmine grade codes -> display form. Only grades that exist in the user's
// data are ever rendered, so this never invents a grade.
const DNA_GRADE_DISPLAY = { M: "M", NM: "NM", VG_PLUS: "VG+", VG: "VG", G_PLUS: "G+", G: "G", F: "F", P: "P" };
function dnaGradeDisplay(code) { return DNA_GRADE_DISPLAY[code] || code || ""; }
// Honest caption per grade tier (never calls a played-in shelf "collector-grade").
function dnaGradeCaption(code) {
  if (code === "M" || code === "NM" || code === "VG_PLUS") return "collector-grade";
  if (code === "VG" || code === "G_PLUS") return "player-grade";
  return "well-played";
}

// Compute the deterministic dimensions POSTed to the Haiku route (era skew +
// genre gravity + silent canon posture). Returns null dimensions for anything
// below the coverage floor rather than rendering a misleading stat. UNCHANGED
// from the shipped version: the Haiku prompt and its inputs are out of scope.
function computeDna(items) {
  const count = items.length;
  const thisYear = new Date().getFullYear();

  // ---- Era skew (from year) ----
  let era = null;
  const years = items
    .map((r) => parseInt(r.year, 10))
    .filter((y) => Number.isFinite(y) && y >= 1900 && y <= thisYear + 1);
  if (count > 0 && years.length / count >= DNA_COVERAGE_MIN) {
    const byDecade = {};
    years.forEach((y) => {
      const d = Math.floor(y / 10) * 10;
      byDecade[d] = (byDecade[d] || 0) + 1;
    });
    const decadeEntries = Object.keys(byDecade)
      .map((d) => [parseInt(d, 10), byDecade[d]])
      .sort((a, b) => b[1] - a[1] || b[0] - a[0]);
    const sorted = years.slice().sort((a, b) => a - b);
    const median = sorted[Math.floor(sorted.length / 2)];
    era = {
      centreDecade: decadeEntries[0][0],
      centreYear: median,
      spreadDecades: decadeEntries.length,
      coverage: years.length / count,
      // [["1970s", 22], ...] for both the brief payload and the visible bar.
      topDecades: decadeEntries.map(([d, n]) => [dnaDecadeLabel(d), n]),
    };
  }

  // ---- Genre gravity (from genre) ----
  let genre = null;
  const genreVals = items
    .map((r) => dnaCleanGenre(r.genre))
    .filter((g) => g && g.toLowerCase() !== "other");
  if (count > 0 && genreVals.length / count >= DNA_COVERAGE_MIN) {
    const byGenre = {};
    genreVals.forEach((g) => {
      const key = g.toLowerCase();
      if (!byGenre[key]) byGenre[key] = { label: g, n: 0 };
      byGenre[key].n += 1;
    });
    const entries = Object.keys(byGenre)
      .map((k) => byGenre[k])
      .sort((a, b) => b.n - a.n);
    // Share of the whole collection (honest denominator), rounded for display.
    const top = entries.map((e) => [e.label, e.n / count]);
    const topShare = top.length ? top[0][1] : 0;
    const top3Share = top.slice(0, 3).reduce((s, x) => s + x[1], 0);
    let concentration = "balanced";
    if (topShare >= 0.5) concentration = "focused";
    else if (entries.length >= 6 && top3Share < 0.6) concentration = "wide";
    genre = {
      top: top,
      distinct: entries.length,
      concentration: concentration,
      coverage: genreVals.length / count,
    };
  }

  // ---- Canon posture (SILENT, Haiku context only, never displayed) ----
  const canonItems = items.filter((r) => r.slug);
  const ownedOfHundred = new Set(canonItems.map((r) => r.slug)).size;
  const canonSharePct = count > 0 ? Math.round((canonItems.length / count) * 100) : 0;
  const ranks = canonItems
    .map((r) => r.album && r.album.rank)
    .filter((n) => typeof n === "number" && n > 0);
  const avgRank = ranks.length ? Math.round(ranks.reduce((s, n) => s + n, 0) / ranks.length) : null;
  let lean = "a mix of canon and deeper cuts";
  if (canonSharePct >= 40) lean = "canon forward, toward the safe and celebrated";
  else if (canonSharePct <= 12) lean = "toward deeper cuts, away from the obvious canon";
  const canon = { ownedOfHundred, canonSharePct, avgRank, lean };

  return { count, era, genre, canon };
}

// The visible fingerprint blocks (SPAN + THE HUNDRED) and the Deep Read blocks
// (VALUE SHAPE + CONDITION + MOST VALUABLE), computed live from the owner's own
// collection. Kept SEPARATE from computeDna on purpose: these carry real album
// titles, and computeDna's output is what gets POSTed to Haiku, which must never
// name a specific record. Each field stays null unless its data honestly exists.
function computeDnaExtras(items) {
  const count = items.length;
  const thisYear = new Date().getFullYear();

  // ---- SPAN: oldest and newest real pressing, with the year gap ----
  let span = null;
  const withYear = items
    .map((r) => parseInt(r.year, 10))
    .filter((y) => Number.isFinite(y) && y >= 1900 && y <= thisYear + 1);
  if (withYear.length >= 2) {
    const min = Math.min.apply(null, withYear);
    const max = Math.max.apply(null, withYear);
    if (max > min) span = { years: max - min, oldYear: min, newYear: max };
  }

  // ---- THE HUNDRED: distinct canon crossover count out of the real 100 ----
  const canonTotal = (window.BBR_ALBUMS && window.BBR_ALBUMS.length) || 100;
  const ownedOfHundred = new Set(items.filter((r) => r.slug).map((r) => r.slug)).size;
  const hundred = { owned: ownedOfHundred, total: canonTotal };

  // ---- VALUE SHAPE + MOST VALUABLE: estimated value ONLY. Records with no
  //      valuation are excluded from the maths, and the basis is stated when the
  //      valued set is a subset. If nothing is valued, both stay null (honest
  //      empty state in the card, never a placeholder figure). ----
  let valueShape = null, mostValuable = null;
  const valued = items.filter((r) => typeof r.value === "number" && r.value > 0);
  if (valued.length >= 1) {
    const total = valued.reduce((s, r) => s + r.value, 0);
    const sorted = valued.slice().sort((a, b) => b.value - a.value);
    const top = sorted[0];
    mostValuable = {
      title: top.title || (top.album && top.album.title) || "Untitled",
      value: Math.round(top.value),
      artist: top.artist || (top.album && top.album.artist) || null,
      year: top.year || (top.album && top.album.year) || null,
      // Carry the resolved album through so the card can show the cover via
      // <Sleeve> (falls back to the typographic sleeve when there's no art).
      album: top.album || null,
    };
    if (total > 0) {
      const k = Math.min(3, sorted.length);
      const topK = sorted.slice(0, k).reduce((s, r) => s + r.value, 0);
      const pct = Math.round((topK / total) * 100);
      let label = "Evenly spread";
      if (pct >= 55) label = "Top-heavy";
      else if (pct >= 30) label = "Weighted";
      valueShape = { pct, k, label, partial: valued.length < count };
    }
  }

  // ---- CONDITION: the dominant Goldmine grade among graded records, with its
  //      share OF GRADED records (stated), so we never present a partial figure
  //      as a whole-collection total. Only renders a grade that exists. ----
  let condition = null;
  const graded = items.filter((r) => r.media_condition);
  if (graded.length >= 1) {
    const hist = {};
    graded.forEach((r) => { hist[r.media_condition] = (hist[r.media_condition] || 0) + 1; });
    const modeGrade = Object.keys(hist).sort((a, b) => hist[b] - hist[a])[0];
    const pct = Math.round((hist[modeGrade] / graded.length) * 100);
    condition = { grade: modeGrade, pct, gradedCount: graded.length, partial: graded.length < count };
  }

  return { span, hundred, valueShape, mostValuable, condition };
}

// Deterministic fallback paragraph, assembled from whatever dimensions exist.
// Never empty, never an error, no em dashes, no canon numbers.
function dnaFallbackRead(dims) {
  const bits = [];
  if (dims.genre && dims.genre.top.length) {
    const lead = dims.genre.top[0][0];
    const shape = dims.genre.concentration === "focused"
      ? "a focused collection that knows what it likes"
      : dims.genre.concentration === "wide"
      ? "a wide ranging collection that refuses to sit still"
      : "a balanced collection with a clear centre";
    bits.push("This is " + shape + ", built around " + lead + ".");
  } else {
    bits.push("This is a collection still finding its centre of gravity.");
  }
  if (dims.era) {
    const spread = dims.era.spreadDecades >= 4
      ? "ranges across " + dims.era.spreadDecades + " decades"
      : "stays close to home in time";
    bits.push("It is centred on the " + dnaDecadeLabel(dims.era.centreDecade) + " and " + spread + ".");
  }
  return bits.join(" ");
}

// The line fills continuously with every record, not in milestone jumps.
// Segment 1 (FINGERPRINT node -> THE READ node) maps records 0 to 34 across the
// first half of the line; segment 2 (THE READ -> DEEP READ) maps 35 to 50 across
// the second half. Clamped 0..100.
function dnaLadderFill(n) {
  if (n <= 34) return (Math.max(0, Math.min(34, n)) / 34) * 50;
  return 50 + (Math.max(0, Math.min(15, n - 35)) / 15) * 50;
}

// --- small presentational pieces --------------------------------------------

function DnaCheck() {
  return (
    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M5 13l4 4L19 7" />
    </svg>
  );
}

function DnaLockMark() {
  return (
    <div className="dna-lock-mark" aria-hidden="true">
      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
        <rect x="5" y="11" width="14" height="9" rx="2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" />
      </svg>
    </div>
  );
}

// The always-visible progress ladder. The rail fill is decorative (aria-hidden);
// the node labels are real text so a screen reader still reads the milestones.
function DnaLadder({ count }) {
  const fill = dnaLadderFill(count);
  const nodes = [
    { label: "Fingerprint", at: DNA_FINGERPRINT_MIN },
    { label: "The Read", at: DNA_READ_MIN },
    { label: "Deep Read", at: DNA_DEEP_MIN },
  ];
  return (
    <div className="dna-ladder" role="group" aria-label="Collection DNA progress">
      <div className="dna-ladder-rail" aria-hidden="true">
        <span className="dna-ladder-fill" style={{ width: fill + "%" }} />
        {nodes.map((nd, i) => {
          const done = count >= nd.at;
          return (
            <span key={nd.label} className={"dna-node-dot" + (done ? " done" : "")} style={{ left: (i * 50) + "%" }}>
              {done && <DnaCheck />}
            </span>
          );
        })}
      </div>
      <div className="dna-ladder-labels">
        {nodes.map((nd) => (
          <span key={nd.label} className={"dna-node-label" + (count >= nd.at ? " done" : "")}>{nd.label}</span>
        ))}
      </div>
    </div>
  );
}

// A mini distribution bar for a card.
function DnaMiniBar({ pct }) {
  const w = Math.max(0, Math.min(100, pct || 0));
  return <div className="dna-card-bar" aria-hidden="true"><span style={{ width: w + "%" }} /></div>;
}

// A fingerprint / deep-read stat card. `href` makes the whole card a link.
function DnaCard({ label, href, className, children }) {
  const cls = "dna-card" + (href ? " dna-card-link" : "") + (className ? " " + className : "");
  const inner = (
    <React.Fragment>
      <div className="dna-card-k">{label}</div>
      {children}
    </React.Fragment>
  );
  return href ? <a className={cls} href={href}>{inner}</a> : <div className={cls}>{inner}</div>;
}

// --- locked / blurred groups (skeletons only, never real data) ---------------
// Locked content is a content-free skeleton, so nothing readable or selectable
// leaks underneath, and it is aria-hidden so a screen reader skips it entirely.
// The lock veil carries its own readable copy.

function DnaSkelCard() {
  return (
    <div className="dna-card dna-skel">
      <div className="dna-skel-k" /><div className="dna-skel-v" /><div className="dna-skel-bar" />
    </div>
  );
}
function DnaSkelRow({ n }) {
  return <div className="dna-cards">{Array.from({ length: n }).map((_, i) => <DnaSkelCard key={i} />)}</div>;
}
function DnaSkelBubble() {
  return (
    <div className="dna-read-bubble dna-skel-bubble">
      <div className="dna-skel-k" /><div className="dna-skel-line" /><div className="dna-skel-line short" />
    </div>
  );
}
function DnaLocked({ copy, children }) {
  return (
    <div className="dna-lockgroup">
      <div className="dna-lockgroup-blur" aria-hidden="true">{children}</div>
      <div className="dna-lockgroup-veil">
        <DnaLockMark />
        <p className="dna-lock-copy">{copy}</p>
      </div>
    </div>
  );
}

// --- The Read dark bubble -----------------------------------------------------

function DnaReadBubble({ opened, read, loading, onOpen }) {
  if (!opened) {
    return (
      <div className="dna-read-bubble">
        <div className="dna-read-eyebrow">Your turn</div>
        <h3 className="dna-read-headline">Your taste profile is <span className="dna-read-em">ready</span></h3>
        <p className="dna-read-support">Thirty-five records in. We've read your collection and written it up.</p>
        <button type="button" className="dna-read-open" onClick={onOpen}>Open your taste profile</button>
      </div>
    );
  }
  return (
    <div className="dna-read-bubble dna-read-bubble-open">
      <div className="dna-read-open-head">
        <div className="dna-read-eyebrow">The Read</div>
        <h3 className="dna-read-headline">Your <span className="dna-read-em">taste</span>, in a paragraph</h3>
      </div>
      <div className="dna-read-open-body" aria-busy={loading && !read ? "true" : "false"}>
        {read
          ? <p className="dna-read-para">{read}</p>
          : <p className="dna-read-para dna-read-para-loading">Reading your collection…</p>}
      </div>
    </div>
  );
}

// --- main component -----------------------------------------------------------

function CollectionDNA({ items }) {
  const store = window.BBR_store;
  const count = items.length;
  const dims = useDnaMemo(() => computeDna(items), [items]);
  const extras = useDnaMemo(() => computeDnaExtras(items), [items]);

  // DNA shift banner (Part 8). Show when a regeneration the owner has not seen
  // exists AND there was a prior read, and NOT when the shift happened this very
  // session (it announces on the next visit).
  const panelRef = useDnaRef(null);
  const showShift = store.dnaReadSeen === false && !!store.dnaReadPrev && !store.dnaRegenerated;
  function onShiftTap() {
    store.markDnaSeen();
    if (panelRef.current) { try { panelRef.current.scrollIntoView({ behavior: "smooth", block: "start" }); } catch (e) {} }
  }
  function onShiftDismiss() { store.markDnaSeen(); }

  // Freshly generated / fallback read for this session. The cached read is read
  // straight off the store each render (so a late profile load is picked up).
  const [generated, setGenerated] = useDnaState(null);
  const [loading, setLoading] = useDnaState(false);
  const inflight = useDnaRef(false);

  // The read (and the Haiku call) now unlock at 35, not 50. Pre-generating at 35
  // means the paragraph is ready the instant the owner opens the bubble.
  useDnaEffect(() => {
    if (count < DNA_READ_MIN) return;
    const cached = store.dnaRead;
    const cachedAt = store.dnaReadCount;
    const needsRegen = !cached || cachedAt == null || Math.abs(count - cachedAt) >= DNA_REGEN_DELTA;
    if (!needsRegen) return;                       // stable: render the cached read, no call
    if (inflight.current) return;                  // one request in flight at a time
    inflight.current = true;
    setLoading(true);
    let cancelled = false;
    fetch("/api/identify", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ kind: "dna", dims: dims }),
    })
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error("dna " + r.status))))
      .then((j) => {
        if (cancelled) return;
        if (j && typeof j.read === "string" && j.read.trim()) {
          setGenerated(j.read.trim());
          store.saveDnaRead(j.read.trim(), count);  // cache once
        } else {
          setGenerated(dnaFallbackRead(dims));      // junk reply -> template
        }
      })
      .catch(() => { if (!cancelled) setGenerated(dnaFallbackRead(dims)); })
      .finally(() => { inflight.current = false; if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, [count, dims]);

  const read = generated || store.dnaRead;
  // The Read defaults back to its "Open your taste profile" CTA on every load
  // (per Luca's device QA): opened is session-only, not seeded from the persisted
  // flag, so the panel never stays permanently expanded. We still record the
  // first-ever open to profiles.dna_read_opened (harmless, guarded) for analytics.
  const [opened, setOpened] = useDnaState(false);
  function openRead() { setOpened(true); store.setDnaReadOpened(); }

  const showFingerprint = count >= DNA_FINGERPRINT_MIN;
  const showRead = count >= DNA_READ_MIN;
  const showDeep = count >= DNA_DEEP_MIN;

  // ---- The four Fingerprint cards (rendered when unlocked) ----
  const eraTop = dims.era && dims.era.topDecades && dims.era.topDecades[0];
  const fingerprintCards = (
    <div className="dna-cards">
      <DnaCard label="Era">
        {eraTop ? (
          <React.Fragment>
            <div className="dna-card-v">{eraTop[0]}</div>
            <DnaMiniBar pct={(eraTop[1] / count) * 100} />
            <div className="dna-card-sub">{eraTop[1]} {eraTop[1] === 1 ? "record" : "records"}</div>
          </React.Fragment>
        ) : <div className="dna-card-v dna-card-empty">Not enough year data</div>}
      </DnaCard>

      <DnaCard label="Genre">
        {dims.genre && dims.genre.top.length ? (
          <React.Fragment>
            <div className="dna-card-v">{dims.genre.top[0][0]}</div>
            <DnaMiniBar pct={dims.genre.top[0][1] * 100} />
            <div className="dna-card-sub">{Math.round(dims.genre.top[0][1] * 100)}% of your shelf</div>
          </React.Fragment>
        ) : <div className="dna-card-v dna-card-empty">Not enough genre data</div>}
      </DnaCard>

      <DnaCard label="Span">
        {extras.span ? (
          <React.Fragment>
            <div className="dna-card-v">{extras.span.years} years</div>
            <div className="dna-card-sub">{extras.span.oldYear} to {extras.span.newYear}</div>
          </React.Fragment>
        ) : <div className="dna-card-v dna-card-empty">Not enough year data</div>}
      </DnaCard>

      <DnaCard label="The Hundred" href="/list">
        <div className="dna-card-v">{extras.hundred.owned} of {extras.hundred.total}</div>
        <div className="dna-card-sub">in the canon</div>
      </DnaCard>
    </div>
  );

  // ---- The three Deep Read cards (rendered when unlocked) ----
  const deepCards = (
    <div className="dna-cards dna-cards-3">
      <DnaCard label="Value shape">
        {extras.valueShape ? (
          <React.Fragment>
            <div className="dna-card-v">{extras.valueShape.label}</div>
            <DnaMiniBar pct={extras.valueShape.pct} />
            <div className="dna-card-sub">
              Top {extras.valueShape.k} = {extras.valueShape.pct}% of value{extras.valueShape.partial ? ", of valued records" : ""}
            </div>
          </React.Fragment>
        ) : <div className="dna-card-v dna-card-empty">No valuations yet</div>}
      </DnaCard>

      <DnaCard label="Condition">
        {extras.condition ? (
          <React.Fragment>
            <div className="dna-card-v">{dnaGradeDisplay(extras.condition.grade)}</div>
            <DnaMiniBar pct={extras.condition.pct} />
            <div className="dna-card-sub">
              {extras.condition.pct}% {dnaGradeCaption(extras.condition.grade)}{extras.condition.partial ? ", of graded records" : ""}
            </div>
          </React.Fragment>
        ) : <div className="dna-card-v dna-card-empty">No grades yet</div>}
      </DnaCard>

      <DnaCard label="Most valuable" className="dna-card-mv">
        {extras.mostValuable ? (
          <div className="dna-mv-inner">
            {extras.mostValuable.album && window.Sleeve && (
              <div className="dna-mv-cover"><Sleeve album={extras.mostValuable.album} size={56} /></div>
            )}
            <div className="dna-mv-text">
              <div className="dna-card-mv-title">{extras.mostValuable.title}</div>
              <div className="dna-card-mv-val"><span className="cur">£</span>{extras.mostValuable.value.toLocaleString("en-GB")}</div>
              <div className="dna-card-sub">
                {extras.mostValuable.artist ? extras.mostValuable.artist : "Unknown artist"}
                {extras.mostValuable.year ? ", " + extras.mostValuable.year : ""}
              </div>
            </div>
          </div>
        ) : <div className="dna-card-v dna-card-empty">No valuations yet</div>}
      </DnaCard>
    </div>
  );

  // ---- Copy for the lock veils ----
  const fingerprintToGo = Math.max(0, DNA_FINGERPRINT_MIN - count);
  const readToGo = Math.max(0, DNA_READ_MIN - count);
  const deepToGo = Math.max(0, DNA_DEEP_MIN - count);
  const fullLockCopy = "Your Collection DNA unlocks at " + DNA_FINGERPRINT_MIN + " records. " + fingerprintToGo + " to go.";
  const readLockCopy = "Your taste profile unlocks at " + DNA_READ_MIN + " records. " + readToGo + " to go.";
  const deepLockCopy = "The deep read unlocks at " + DNA_DEEP_MIN + " records. " + deepToGo + " to go.";

  return (
    <React.Fragment>
      {showShift && (
        <div className="dna-shift">
          <button type="button" className="dna-shift-main" onClick={onShiftTap}>
            Your Collection DNA has shifted. <span className="dna-shift-cta">See what changed.</span>
          </button>
          <button type="button" className="dna-shift-x" aria-label="Dismiss" onClick={onShiftDismiss}>×</button>
        </div>
      )}
      <div className="dna" ref={panelRef}>
        <div className="dna-eyebrow">Collection DNA</div>

        {/* The progress ladder is always visible, from record one. */}
        <DnaLadder count={count} />

        {/* 0-19: the entire content area is blurred behind one lock. */}
        {!showFingerprint ? (
          <DnaLocked copy={fullLockCopy}>
            <DnaSkelRow n={4} />
            <DnaSkelBubble />
            <DnaSkelRow n={3} />
          </DnaLocked>
        ) : (
          <React.Fragment>
            {/* The Fingerprint (20+) */}
            <div className="dna-group">
              <div className="dna-group-k">The Fingerprint</div>
              {fingerprintCards}
            </div>

            {/* The Read (35+), else locked */}
            <div className="dna-group">
              <div className="dna-group-k">The Read</div>
              {showRead
                ? <DnaReadBubble opened={opened} read={read} loading={loading} onOpen={openRead} />
                : <DnaLocked copy={readLockCopy}><DnaSkelBubble /></DnaLocked>}
            </div>

            {/* The Deep Read (50+), else locked */}
            <div className="dna-group">
              <div className="dna-group-k">The Deep Read</div>
              {showDeep
                ? deepCards
                : <DnaLocked copy={deepLockCopy}><DnaSkelRow n={3} /></DnaLocked>}
            </div>
          </React.Fragment>
        )}
      </div>
    </React.Fragment>
  );
}

// Expose the component plus the pure helpers (handy for headless QA).
Object.assign(window, { CollectionDNA, computeDna, computeDnaExtras, dnaFallbackRead, dnaLadderFill });
