// ===========================================================
// CollectionAppV2 — the logged-in My Collection page (v2).
// Collection reads collection_items (store.collectionV2); the add
// flow is MyCollectionAdd_v2 with the public-collection consent gate.
// Wishlist is unchanged (store.wishlist from `records`, old add flow).
// Reuses globals defined elsewhere: Sleeve, AnimatedMoney, WishCard,
// AddRecordFlow, MyCollectionAdd_v2, BBR_store, BBR_CONDITIONS.
// ===========================================================
const { useState: useV2State, useMemo: useV2Memo, useEffect: useV2Effect } = React;

function v2gbp(n) { return "£" + Math.round(n || 0).toLocaleString("en-GB"); }
// DB enum grade -> display code (VG_PLUS -> VG+).
function gradeDisplay(code) { return code ? code.replace("_PLUS", "+") : "—"; }
function v2condClass(code) {
  const c = gradeDisplay(code);
  return c === "VG+" ? "VGplus" : (["M", "NM", "VG"].includes(c) ? c : "low");
}
function v2decade(y) { return y ? (Math.floor(y / 10) * 10 + "s") : "—"; }

// ---- sort keys -------------------------------------------------------------
// First-name sort key: the artist string as-is, trimmed + lowercased.
// "David Bowie" sorts under D.
function v2ArtistFirst(artist) { return String(artist || "").trim().toLowerCase(); }
// Surname sort key: the LAST whitespace-delimited token, trimmed + lowercased.
// Bands and mononyms are NOT treated as people — we just take the last token
// consistently, which is predictable and good enough: "David Bowie" -> "bowie",
// "The Beatles" -> "beatles", "Pink Floyd" -> "floyd", "Madonna" -> "madonna".
// No "is this a band" detection, and no leading-"The" stripping (the existing
// sorts don't strip it either, so we stay consistent).
function v2ArtistLast(artist) {
  const s = String(artist || "").trim();
  if (!s) return "";
  const parts = s.split(/\s+/);
  return parts[parts.length - 1].toLowerCase();
}
// A->Z string compare with empty/missing keys always sorted last.
function v2cmpStr(ka, kb) {
  if (!ka && !kb) return 0;
  if (!ka) return 1;
  if (!kb) return -1;
  return ka.localeCompare(kb);
}
// Year compare; null/missing year always sorts last regardless of direction.
function v2cmpYear(a, b, dir) {
  const ya = (typeof a.year === "number" && a.year) ? a.year : null;
  const yb = (typeof b.year === "number" && b.year) ? b.year : null;
  if (ya == null && yb == null) return 0;
  if (ya == null) return 1;
  if (yb == null) return -1;
  return dir === "asc" ? ya - yb : yb - ya;
}

// Resolve a collection_items row into a render object (album for <Sleeve>).
function resolveV2(item, albums) {
  const album = item.slug
    ? (albums.find(a => a.slug === item.slug) || { title: item.title, artist: item.artist, year: item.year, rank: -1, slug: item.slug })
    : { title: item.title, artist: item.artist, year: item.year, rank: -1, slug: null, genre: item.genre };
  return {
    ...item,
    album,
    value: typeof item.est_value === "number" ? item.est_value : 0,
    // "Valuation loading": added (e.g. bulk-imported) but not yet priced — no
    // value AND never repriced. Once the reprice job runs, repriced_at is set,
    // so a genuinely value-less record (no market data) reads as "unvalued",
    // not "pending". Keeps the two states honestly distinct.
    pending: item.est_value == null && item.repriced_at == null,
    genre: item.genre || "Other",
    decade: v2decade(item.year),
  };
}

// Fetch the owner's LIVE valuation snapshots from `table`, keyed by `idCol`,
// grouped per id as {v,t} ascending. Bounded to the last ~70 days (ample for the
// 30-day tile window and the week-over-week portfolio) AND paginated, because
// PostgREST caps a single response at db.max_rows (default 1000): a large
// collection would otherwise silently drop its most recent weeks and break the
// deltas. Returns {} on any error (e.g. the wishlist table absent pre-migration).
const V2_SNAP_WINDOW_DAYS = 70;
async function v2FetchLiveSnaps(table, idCol) {
  if (!window.BBR_supabase) return {};
  const sinceIso = new Date(Date.now() - V2_SNAP_WINDOW_DAYS * 86400000).toISOString();
  const PAGE = 1000;
  const by = {};
  for (let from = 0; ; from += PAGE) {
    let resp;
    try {
      resp = await window.BBR_supabase
        .from(table)
        .select(idCol + ",value,captured_at")
        .eq("is_estimated", false)
        .gte("captured_at", sinceIso)
        .order("captured_at", { ascending: true })
        .range(from, from + PAGE - 1);
    } catch (e) { break; }
    if (!resp || resp.error) break;
    const data = resp.data || [];
    data.forEach(row => {
      const v = Number(row.value);
      if (!isFinite(v)) return;
      const id = row[idCol];
      (by[id] || (by[id] = [])).push({ v, t: new Date(row.captured_at).getTime() });
    });
    if (data.length < PAGE) break;
  }
  return by;
}

// Per-item movement from the LIVE valuation snapshots only (Part 3). Estimated
// (backfill) snapshots never count toward movement. Needs >= 2 live prints or
// returns null (the tile then shows no indicator at all). Baseline = the live
// snapshot closest to 30 days before the latest; with under 30 days of live
// history that naturally resolves to the earliest live print. `list` is this
// item's live snapshots as {v,t}, ascending by capture time.
const V2_DAY = 86400000;
function v2LiveDelta(list) {
  if (!Array.isArray(list) || list.length < 2) return null;
  const latest = list[list.length - 1];
  const target = latest.t - 30 * V2_DAY;
  let base = null, bd = Infinity;
  for (let i = 0; i < list.length - 1; i++) {   // never the latest point itself
    const d = Math.abs(list[i].t - target);
    if (d < bd) { bd = d; base = list[i]; }
  }
  return Math.round(latest.v) - Math.round(base.v);
}

// Provenance badge presentation, keyed on verification_level. `catalogue`
// (added via the catalogue-number pressing-match flow) carries real provenance,
// so it gets the verified visual treatment — like `scanned`, not `manual`.
function provBadge(level) {
  if (level === "scanned")   return { cls: "scanned",   label: "✓ scanned", title: "Added by barcode scan" };
  if (level === "catalogue") return { cls: "catalogue", label: "cat no",     title: "Added by catalogue number (pressing matched)" };
  return { cls: "manual", label: "manual", title: "Added manually" };
}

function V2Card({ r, liveDelta, onRemove, onOpen }) {
  // Remove was a single tap on a 26px icon with no confirmation and no undo,
  // permanently visible on mobile, 12px from the corner of a card whose whole
  // surface is also tappable. One mis-tap destroyed a record. Two-step now.
  const [confirming, setConfirming] = useV2State(false);
  useV2Effect(() => {
    if (!confirming) return;
    const t = setTimeout(() => setConfirming(false), 4000);
    return () => clearTimeout(t);
  }, [confirming]);
  const prov = provBadge(r.verification_level);
  // Movement (Part 3): latest live snapshot vs the live snapshot ~30 days prior,
  // computed once for all tiles in the parent (v2LiveDelta). Estimated snapshots
  // never count; fewer than 2 live prints -> liveDelta is undefined -> no badge.
  const delta = typeof liveDelta === "number" ? liveDelta : null;
  // The whole tile opens the detail view (where the value chart lives). On
  // mobile the details affordance was a 13px icon that most people never found;
  // the tile is the obvious tap target. The icon buttons stay for an explicit
  // hit + keyboard access, and stop propagation so Remove never opens details.
  return (
    <div className={"mc-card mc-card-tappable" + (confirming ? " mc-card-confirming" : "")} onClick={() => onOpen(r)} title="View details">
      <div className="mc-card-actions">
        {/* Hidden mid-confirmation: the cluster is absolute and the card only
            reserves room for its collapsed width, so every extra control in it
            pushes Remove/Keep further over the card's own text. It also parks a
            44px hit target immediately beside the destructive button. */}
        {!confirming && (
        <button className="mc-iconbtn" title="Details & purchase info" onClick={(e) => { e.stopPropagation(); onOpen(r); }}>
          <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M9.4 1.7l2.9 2.9M8.2 2.9l-6 6L1.5 12.5l3.6-.7 6-6z"/></svg>
        </button>
        )}
        {confirming ? (
          <span className="mc-confirm" onClick={(e) => e.stopPropagation()}>
            <button className="mc-confirm-yes" onClick={(e) => { e.stopPropagation(); onRemove(r.id); }}>
              Remove
            </button>
            <button className="mc-confirm-no" onClick={(e) => { e.stopPropagation(); setConfirming(false); }}>
              Keep
            </button>
          </span>
        ) : (
          <button className="mc-iconbtn danger" title={"Remove " + r.title} aria-label={"Remove " + r.title + " from your collection"} onClick={(e) => { e.stopPropagation(); setConfirming(true); }}>
            <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M2 3.5h10M5 3.5V2h4v1.5M3.5 3.5l.6 8.5h5.8l.6-8.5"/></svg>
          </button>
        )}
      </div>
      <div className="mc-card-cover"><Sleeve album={r.album} size={76} /></div>
      <div className="mc-card-body">
        <div className="mc-card-top">
          <div>
            <div className="mc-card-title">{r.title}</div>
            <div className="mc-card-artist">{r.artist}{r.year ? " · " + r.year : ""}</div>
          </div>
          <span className={"mc-cond " + v2condClass(r.media_condition)} title="Media / Sleeve">
            {gradeDisplay(r.media_condition)}<small style={{ opacity: .6 }}>/{gradeDisplay(r.sleeve_condition)}</small>
          </span>
        </div>
        <div className="mc-card-meta">
          {r.genre || "—"}{r.slug ? " · in the Hundred" : ""}
          <span className={"v2-badge " + prov.cls} title={prov.title}>
            {prov.label}
          </span>
        </div>
        <div className="mc-card-foot">
          <div>
            {r.pending ? (
              <div className="mc-card-val pending">Valuation loading</div>
            ) : (
              <div className="mc-card-val"><span className="cur">£</span><AnimatedMoney value={r.value} /></div>
            )}
            <div className="mc-card-range">{r.pending ? "live price, up to 48h" : (r.value > 0 ? "est. value" : "unvalued")}</div>
          </div>
          {delta != null && (
            Math.abs(delta) < 1
              ? <span className="mc-delta flat" title="From live valuations over the last 30 days">No change</span>
              : <span className={"mc-delta " + (delta > 0 ? "up" : "down")} title="From live valuations over the last 30 days">
                  <span className="mc-arrow">{delta > 0 ? "▲" : "▼"}</span>£{Math.abs(delta).toLocaleString("en-GB")}
                </span>
          )}
        </div>
      </div>
    </div>
  );
}

// ---- whole-collection visibility toggle ------------------------------------
// The single user-facing control for public/private. Reads/writes the one
// source of truth (store.isPublic -> profiles.is_public). Going public is
// consent-gated inline; going private gets a calm confirm. Server-side
// enforcement lives in the DB (collection_visibility.sql) — this is the UI.
function VisibilityToggle() {
  const store = window.BBR_store;
  const isPublic = store.isPublic;
  const [panel, setPanel] = useV2State(null);   // null | 'consent' | 'private'
  const [ack, setAck] = useV2State(false);
  const [busy, setBusy] = useV2State(false);
  const [msg, setMsg] = useV2State("");          // '' | 'saved' | 'err'

  async function apply(makePublic) {
    setBusy(true); setMsg("");
    try {
      await store.setVisibility(makePublic);
      setPanel(null); setAck(false);
      setMsg("saved");
      setTimeout(() => setMsg(m => (m === "saved" ? "" : m)), 2600);
    } catch (e) {
      setMsg("err");
    } finally { setBusy(false); }
  }

  function onSwitch() {
    if (busy) return;
    setMsg("");
    if (isPublic) setPanel("private");           // public -> private: confirm
    else if (store.acceptedTerms) apply(true);   // already consented -> straight to public
    else { setAck(false); setPanel("consent"); } // first time public -> consent inline
  }

  return (
    <div className={"v2-vis " + (isPublic ? "is-public" : "is-private")}>
      <div className="v2-vis-main">
        <button
          type="button" role="switch" aria-checked={isPublic} disabled={busy}
          aria-label={"Collection visibility — currently " + (isPublic ? "public" : "private")}
          className={"v2-vis-switch" + (isPublic ? " on" : "")} onClick={onSwitch}>
          <span className="v2-vis-knob" />
        </button>
        <div className="v2-vis-text">
          <div className="v2-vis-state">
            Your collection is <b>{isPublic ? "Public" : "Private"}</b>
            {busy && <span className="v2-vis-flag saving">Saving…</span>}
            {!busy && msg === "saved" && <span className="v2-vis-flag saved">Saved ✓</span>}
          </div>
          <div className="v2-vis-explain">
            {isPublic
              ? "Anyone can view your collection, and you appear on the leaderboards under your display name."
              : "Only you can see your collection. You still appear on the leaderboards — anonymously, so your rank counts but your name doesn't."}
          </div>
          {msg === "err" && (
            <div className="v2-vis-explain v2-vis-err">
              Couldn't save that just now. <button className="v2-vis-retry" onClick={() => apply(!isPublic)}>Try again</button>
            </div>
          )}
        </div>
      </div>

      {panel === "consent" && (
        <div className="v2-vis-panel">
          <div className="v2-vis-panel-h">Make your collection public</div>
          <p className="v2-vis-panel-lede">When your collection is public:</p>
          <ul className="v2-vis-panel-list">
            <li>Your collection — <b>including each item's condition and estimated value</b> — is visible to anyone, signed in or not.</li>
            <li>You appear on the <b>public leaderboards</b> under your display name, linking to your collection page.</li>
            <li>Showing the value maths openly is how the community keeps each other honest.</li>
          </ul>
          <label className="v2-vis-ack">
            <input type="checkbox" checked={ack} onChange={e => setAck(e.target.checked)} />
            <span>I understand my collection and its values will be public.</span>
          </label>
          <div className="v2-vis-actions">
            <button className="v2-vis-cancel" onClick={() => { setPanel(null); setAck(false); }} disabled={busy}>Cancel</button>
            <button className="v2-vis-go" disabled={!ack || busy} onClick={() => apply(true)}>{busy ? "Saving…" : "Agree & go public"}</button>
          </div>
        </div>
      )}

      {panel === "private" && (
        <div className="v2-vis-panel">
          <div className="v2-vis-panel-h">Make your collection private?</div>
          <p className="v2-vis-panel-lede">
            You'll be hidden from the leaderboard — your rank still counts, but anonymously, with no name or link. Your public collection page will be switched off. You can make it public again any time.
          </p>
          <div className="v2-vis-actions">
            <button className="v2-vis-cancel" onClick={() => setPanel(null)} disabled={busy}>Keep it public</button>
            <button className="v2-vis-go danger" disabled={busy} onClick={() => apply(false)}>{busy ? "Saving…" : "Make private"}</button>
          </div>
        </div>
      )}
    </div>
  );
}

// ---- folder bar ------------------------------------------------------------
// Filter chips above the collection grid: All / each folder / Unfiled, each
// with a live count. "+ New folder" creates one inline; "Manage" reveals
// rename + delete on each folder chip. Folders are the owner's private filing
// system — organisational only, never surfaced on the public collection page.
function V2FolderBar({ folders, items, filterFolder, setFilterFolder }) {
  const store = window.BBR_store;
  const [creating, setCreating] = useV2State(false);
  const [newName, setNewName] = useV2State("");
  const [managing, setManaging] = useV2State(false);
  const [renameId, setRenameId] = useV2State(null);
  const [renameVal, setRenameVal] = useV2State("");
  const [confirmId, setConfirmId] = useV2State(null);
  const [busy, setBusy] = useV2State(false);

  const unfiledCount = items.filter(r => !r.folder_id).length;
  const countFor = (fid) => items.filter(r => r.folder_id === fid).length;

  async function doCreate() {
    const name = newName.trim();
    if (!name || busy) return;
    setBusy(true);
    try { await store.createFolder(name); setNewName(""); setCreating(false); }
    catch (e) {} finally { setBusy(false); }
  }
  async function doRename(id) {
    const name = renameVal.trim();
    if (!name || busy) return;
    setBusy(true);
    try { await store.renameFolder(id, name); setRenameId(null); setRenameVal(""); }
    catch (e) {} finally { setBusy(false); }
  }
  async function doDelete(id) {
    if (busy) return;
    setBusy(true);
    try {
      await store.deleteFolder(id);
      if (filterFolder === id) setFilterFolder("all");
      if (folders.length <= 1) setManaging(false); // no folders left to manage
      setConfirmId(null);
    }
    catch (e) {} finally { setBusy(false); }
  }

  return (
    <div className="mc-folders">
      <button className={"mc-fchip" + (filterFolder === "all" ? " active" : "")}
        aria-pressed={filterFolder === "all"} onClick={() => setFilterFolder("all")}>
        All <span className="mc-fchip-ct">{items.length}</span>
      </button>

      {folders.map(f => {
        if (renameId === f.id) {
          return (
            <span key={f.id} className="mc-fchip-edit">
              <input autoFocus className="mc-fchip-input" maxLength={60} value={renameVal}
                onChange={e => setRenameVal(e.target.value)}
                onKeyDown={e => { if (e.key === "Enter") doRename(f.id); if (e.key === "Escape") { setRenameId(null); setRenameVal(""); } }} />
              <button className="mc-fchip-mini" disabled={busy || !renameVal.trim()} onClick={() => doRename(f.id)}>Save</button>
              <button className="mc-fchip-mini ghost" onClick={() => { setRenameId(null); setRenameVal(""); }}>Cancel</button>
            </span>
          );
        }
        if (confirmId === f.id) {
          return (
            <span key={f.id} className="mc-fchip-confirm">
              <span className="mc-fchip-confirm-msg">Delete this folder? The records inside stay in your collection and become unfiled.</span>
              <button className="mc-fchip-mini danger" disabled={busy} onClick={() => doDelete(f.id)}>Delete</button>
              <button className="mc-fchip-mini ghost" onClick={() => setConfirmId(null)}>Keep</button>
            </span>
          );
        }
        return (
          <span key={f.id} className="mc-fchip-wrap">
            <button className={"mc-fchip" + (filterFolder === f.id ? " active" : "")}
              aria-pressed={filterFolder === f.id} onClick={() => setFilterFolder(f.id)}>
              {f.name} <span className="mc-fchip-ct">{countFor(f.id)}</span>
            </button>
            {managing && (
              <span className="mc-fchip-tools">
                <button className="mc-fchip-tool" aria-label={"Rename folder " + f.name} title="Rename folder" onClick={() => { setRenameId(f.id); setRenameVal(f.name); }}>✎</button>
                <button className="mc-fchip-tool danger" aria-label={"Delete folder " + f.name} title="Delete folder" onClick={() => setConfirmId(f.id)}>🗑</button>
              </span>
            )}
          </span>
        );
      })}

      {folders.length > 0 && (
        <button className={"mc-fchip" + (filterFolder === "unfiled" ? " active" : "")}
          aria-pressed={filterFolder === "unfiled"} onClick={() => setFilterFolder("unfiled")}>
          Unfiled <span className="mc-fchip-ct">{unfiledCount}</span>
        </button>
      )}

      {creating ? (
        <span className="mc-fchip-edit">
          <input autoFocus className="mc-fchip-input" maxLength={60} placeholder="Folder name" value={newName}
            onChange={e => setNewName(e.target.value)}
            onKeyDown={e => { if (e.key === "Enter") doCreate(); if (e.key === "Escape") { setCreating(false); setNewName(""); } }} />
          <button className="mc-fchip-mini" disabled={busy || !newName.trim()} onClick={doCreate}>Add</button>
          <button className="mc-fchip-mini ghost" onClick={() => { setCreating(false); setNewName(""); }}>Cancel</button>
        </span>
      ) : (
        <button className="mc-fchip new" onClick={() => setCreating(true)}>+ New folder</button>
      )}

      {folders.length > 0 && !creating && (
        <button className={"mc-fchip-manage" + (managing ? " on" : "")}
          onClick={() => { setManaging(m => !m); setRenameId(null); setConfirmId(null); }}>
          {managing ? "Done" : "Manage"}
        </button>
      )}
    </div>
  );
}

// ---- sticky jump nav (Option B) --------------------------------------------
// A thin sticky bar under the site nav that jumps to the page's sections (the
// shelf + each insight) and flips between Collection and Wishlist. Sections are
// passed in from the parent so the bar only ever lists what is actually on
// screen for the current tab and collection size.
function CollectionNav({ tab, sections, activeId, onJump, onTab }) {
  return (
    <nav className="mc-jumpnav" aria-label="Jump to section">
      <div className="mc-jumpnav-inner">
        {sections.map(s => (
          <button
            key={s.id} type="button"
            className={"mc-jumplink" + (activeId === s.id ? " active" : "")}
            aria-current={activeId === s.id ? "true" : undefined}
            onClick={() => onJump(s.id)}>
            {s.label}
          </button>
        ))}
        <button type="button" className="mc-jumplink mc-jumplink-alt" onClick={onTab}>
          {tab === "collection" ? "Wishlist →" : "← Back to collection"}
        </button>
      </div>
    </nav>
  );
}

// ---- Collector level badge ------------------------------------------------
// Prestige tier from total collection size. Subtle chip in the value bar; tap /
// hover opens a tooltip listing every level with the current one highlighted.
const COLLECTOR_TIERS = [
  { name: "Beginner", min: 0 },
  { name: "New Spinner", min: 10 },
  { name: "Enthusiast", min: 50 },
  { name: "Connoisseur", min: 100 },
  { name: "Collector", min: 200 },
  { name: "Serious Collector", min: 500 },
  { name: "Archivist", min: 1000 },
  { name: "Curator", min: 2500 },
  { name: "Master Collector", min: 5000 },
  { name: "Grandmaster Collector", min: 10000 },
];
function collectorTierIndex(count) {
  let i = 0;
  for (let k = 0; k < COLLECTOR_TIERS.length; k++) if (count >= COLLECTOR_TIERS[k].min) i = k;
  return i;
}
function collectorTierRange(i) {
  const min = COLLECTOR_TIERS[i].min, next = COLLECTOR_TIERS[i + 1];
  return next ? min.toLocaleString("en-GB") + "–" + (next.min - 1).toLocaleString("en-GB")
              : min.toLocaleString("en-GB") + "+";
}
function CollectorBadge({ count }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  const idx = collectorTierIndex(count);
  const tier = COLLECTOR_TIERS[idx];
  const next = COLLECTOR_TIERS[idx + 1];
  React.useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("click", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("click", onDoc); document.removeEventListener("keydown", onKey); };
  }, [open]);
  return (
    <span className={"mc-lvl" + (open ? " open" : "")} ref={ref}>
      <button type="button" className="mc-lvl-chip" aria-haspopup="dialog" aria-expanded={open}
        aria-label={"Collector level: " + tier.name + ". Tap for all levels."}
        onClick={(e) => { e.stopPropagation(); setOpen((o) => !o); }}>
        <span className="mc-lvl-dot" aria-hidden="true">◆</span>{tier.name}
      </button>
      {open && (
        <div className="mc-lvl-pop" role="dialog" aria-label="Collector levels">
          <div className="mc-lvl-pop-h">Collector levels</div>
          <ul className="mc-lvl-pop-list">
            {COLLECTOR_TIERS.map((t, i) => (
              <li key={t.name} className={i === idx ? "on" : ""}>
                <span className="mc-lvl-pop-name">{t.name}</span>
                <span className="mc-lvl-pop-range">{collectorTierRange(i)}</span>
              </li>
            ))}
          </ul>
          <div className="mc-lvl-pop-foot">
            {next ? <span>{(next.min - count).toLocaleString("en-GB")} more to <b>{next.name}</b></span>
                  : <span>Top tier — the summit.</span>}
          </div>
        </div>
      )}
    </span>
  );
}

function collectorTierName(count) { return COLLECTOR_TIERS[collectorTierIndex(count)].name; }
// Reused by the public collection page + leaderboard rows (loaded after this file).
Object.assign(window, { CollectorBadge, COLLECTOR_TIERS, collectorTierIndex, collectorTierName });

// Celebrates crossing into a higher tier — in-session (the record that tips you
// over) and cross-session (levelled up via import since last visit). A debounced
// settle guards against the async 0->N hydration jump reading as a level-up.
// Per-account localStorage keys. "bbr:lvl" and "ti_seen" were global, so on a
// shared browser one account cleared the other's level-up baseline and Index dot.
// Falls back to the legacy global key once, so existing users don't get a
// spurious "you levelled up" or a re-lit dot on first load after this change.
function v2UserKey(base) {
  const uid = (window.BBR_store && window.BBR_store.userId) || "anon";
  return base + ":" + uid;
}
function v2LsGet(base) {
  try {
    const v = localStorage.getItem(v2UserKey(base));
    if (v != null) return v;
    const legacy = localStorage.getItem(base);   // one-time inheritance
    if (legacy != null) localStorage.setItem(v2UserKey(base), legacy);
    return legacy;
  } catch (e) { return null; }
}
function v2LsSet(base, val) {
  try { localStorage.setItem(v2UserKey(base), String(val)); } catch (e) {}
}

function CollectorLevelUp({ count }) {
  const [tier, setTier] = React.useState(null);
  const ref = React.useRef({ idx: null, timer: null });
  React.useEffect(() => {
    if (count == null) return;
    const idx = collectorTierIndex(count);
    const st = ref.current;
    if (st.timer) clearTimeout(st.timer);
    st.timer = setTimeout(() => {
      let stored = null;
      const v = v2LsGet("bbr:lvl"); stored = v == null ? null : parseInt(v, 10);
      const baseline = st.idx == null ? stored : st.idx;   // cross-session on first settle, in-session after
      if (baseline != null && idx > baseline) setTier(COLLECTOR_TIERS[idx]);
      st.idx = idx;
      v2LsSet("bbr:lvl", idx);
    }, 900);
    return () => { if (st.timer) clearTimeout(st.timer); };
  }, [count]);
  React.useEffect(() => {
    if (!tier) return;
    const t = setTimeout(() => setTier(null), 11000);
    return () => clearTimeout(t);
  }, [tier]);
  if (!tier) return null;
  const ti = COLLECTOR_TIERS.indexOf(tier);
  const next = COLLECTOR_TIERS[ti + 1];
  const sub = next
    ? <span>You&rsquo;ve climbed to <b>{tier.name}</b>. Next stop: <b>{next.name}</b> at {next.min.toLocaleString("en-GB")} records.</span>
    : <span>You&rsquo;ve reached the summit — <b>{tier.name}</b>, the top of the ladder. Hats off.</span>;
  return (
    <div className="mc-levelup" role="status">
      <span className="mc-levelup-burst" aria-hidden="true">🎉</span>
      <div className="mc-levelup-body">
        <div className="mc-levelup-h">Congratulations — you levelled up!</div>
        <div className="mc-levelup-text">{sub}</div>
      </div>
      <button type="button" className="mc-levelup-x" aria-label="Dismiss" onClick={() => setTier(null)}>×</button>
    </div>
  );
}

function CollectionAppV2({ albums, user, onGate }) {
  const store = window.BBR_store;
  const [, force] = useV2State(0);
  useV2Effect(() => store.subscribe(() => force(n => n + 1)), []);

  // Returning from the Discogs authorise screen lands on /collection?discogs=…
  // Open the importer and let it auto-pull (connected) or show a retry (error),
  // then strip the param so a refresh doesn't re-trigger it.
  useV2Effect(() => {
    try {
      const sp = new URLSearchParams(window.location.search);
      const d = sp.get("discogs");
      if (d === "connected" || d === "error") {
        setDiscogsReturn(d);
        setImportOpen(true);
        sp.delete("discogs");
        const qs = sp.toString();
        window.history.replaceState({}, "", window.location.pathname + (qs ? "?" + qs : ""));
      }
    } catch (e) {}
  }, []);

  const [tab, setTab] = useV2State("collection");
  const [search, setSearch] = useV2State("");
  const [sortKey, setSortKey] = useV2State("value");
  const [filterGenre, setFilterGenre] = useV2State("all");
  const [filterFolder, setFilterFolder] = useV2State("all"); // "all" | folderId | "unfiled"
  const [addOpen, setAddOpen] = useV2State(false);
  const [importOpen, setImportOpen] = useV2State(false); // bulk import (CSV / Discogs)
  const [importFromAdd, setImportFromAdd] = useV2State(false); // importer reached from the add sheet -> back returns there
  const [discogsReturn, setDiscogsReturn] = useV2State(null); // 'connected' | 'error' from the OAuth round-trip
  const [startDiscogs, setStartDiscogs] = useV2State(false); // empty-state "Connect Discogs": open the importer straight into the OAuth handshake
  const [inviteOpen, setInviteOpen] = useV2State(false); // "Invite a friend" share sheet
  const [appraisalOpen, setAppraisalOpen] = useV2State(false); // insurance/probate appraisal document
  const [indexOpen, setIndexOpen] = useV2State(false); // "The Index" daily portfolio report
  // Last date (YYYY-MM-DD) the member opened The Index, persisted client-side so
  // the "new report" dot on the reveal button clears once they've looked today.
  const [indexSeen, setIndexSeen] = useV2State(() => v2LsGet("ti_seen"));
  const [detailId, setDetailId] = useV2State(null); // open item detail/edit modal (by id, so it tracks live store state)
  const [wishAdd, setWishAdd] = useV2State(null);
  const [covers, setCovers] = useV2State({}); // cover-art key -> url|null (non-canon records)
  const coverReq = React.useRef(new Set());

  // Pull real cover art for non-canon (manual, slug=null) records, the same
  // way the leaderboard does (Discogs + iTunes via /api/cover, localStorage-cached).
  useV2Effect(() => {
    if (!window.lbFetchCover) return;
    const seen = new Set();
    const todo = [];
    store.collectionV2.forEach(r => {
      if (r.slug) return;
      const k = window.lbCoverKey(r);
      if (seen.has(k) || coverReq.current.has(k)) return;
      seen.add(k); coverReq.current.add(k); todo.push(r);
    });
    if (!todo.length) return;
    let cancelled = false;
    Promise.all(todo.map(r => window.lbFetchCover(r).then(url => [window.lbCoverKey(r), url])))
      .then(pairs => {
        if (cancelled) return;
        setCovers(prev => { const next = Object.assign({}, prev); pairs.forEach(([k, u]) => { next[k] = u; }); return next; });
      });
    return () => { cancelled = true; };
  }, [store.collectionV2]);

  // Part 3/4: the LIVE valuation-snapshot series, fetched ONCE for all the
  // owner's items and shared by the per-tile movement (Part 3) and the portfolio
  // header + movers (Part 4), so the two never diverge. RLS on valuation_snapshots
  // scopes the read to this member's own items, so no id list is needed; we take
  // only the real prints (is_estimated=false) and group them per item, ascending.
  // Volume is low (one live print per item per week since launch) and the read
  // rides the existing (collection_item_id, captured_at) index, no new index.
  const [liveSnaps, setLiveSnaps] = useV2State(null); // { itemId: [{v,t}, ...] }
  useV2Effect(() => {
    if (!window.BBR_supabase || !user || !user.id) { setLiveSnaps({}); return; }
    let cancelled = false;
    v2FetchLiveSnaps("valuation_snapshots", "collection_item_id")
      .then(by => { if (!cancelled) setLiveSnaps(by); });
    return () => { cancelled = true; };
  }, [user && user.id, store.collectionV2.length]);

  // Per-item movement map (Part 3): itemId -> signed £ delta, present only for
  // items with >= 2 live prints (others render no indicator).
  const liveDeltas = useV2Memo(() => {
    const m = {};
    if (liveSnaps) Object.keys(liveSnaps).forEach(id => {
      const d = v2LiveDelta(liveSnaps[id]);
      if (d !== null) m[id] = d;
    });
    return m;
  }, [liveSnaps]);

  // Grail Watch (Part 7): live wishlist snapshots, same shape/handling as the
  // collection series but keyed on record_id. The table may not exist pre-
  // migration; the query then errors and we fall back to {} (no indicators).
  const [wishSnaps, setWishSnaps] = useV2State(null);
  useV2Effect(() => {
    if (!window.BBR_supabase || !user || !user.id) { setWishSnaps({}); return; }
    let cancelled = false;
    v2FetchLiveSnaps("wishlist_valuation_snapshots", "record_id")
      .then(by => { if (!cancelled) setWishSnaps(by); });
    return () => { cancelled = true; };
  }, [user && user.id, store.wishlist.length]);

  const wishDeltas = useV2Memo(() => {
    const m = {};
    if (wishSnaps) Object.keys(wishSnaps).forEach(id => {
      const d = v2LiveDelta(wishSnaps[id]);
      if (d !== null) m[id] = d;
    });
    return m;
  }, [wishSnaps]);

  // "Moved this week": >= 2 live prints and the latest week differs from the one before.
  const wishMoved = useV2Memo(() => {
    const s = new Set();
    if (wishSnaps) Object.keys(wishSnaps).forEach(id => {
      const l = wishSnaps[id];
      if (l && l.length >= 2 && Math.round(l[l.length - 1].v) !== Math.round(l[l.length - 2].v)) s.add(id);
    });
    return s;
  }, [wishSnaps]);

  const items = useV2Memo(() => store.collectionV2.map(i => {
    const r = resolveV2(i, albums);
    if (!r.album.slug && window.lbCoverKey) {
      const u = covers[window.lbCoverKey(i)];
      if (u) r.album = Object.assign({}, r.album, { coverUrl: u });
    }
    return r;
  }), [store.collectionV2, albums, covers]);
  const folders = store.collectionFolders || [];
  const wishlist = store.wishlist;
  // resolveRecord is a global from MyCollection.jsx (loaded first); reuse it for wishlist cards.
  const resolvedWish = useV2Memo(() => wishlist.map(r => resolveRecord(r, albums)), [wishlist, albums]);

  const total = items.reduce((s, r) => s + r.value, 0);
  // Net value uplift = total est_value − total spend. Cost per item: 0 if gift,
  // else the entered purchase_price, else 0 (no price = treated as £0 spend, so
  // it inflates uplift — hence the coverage caption below keeps it honest).
  const totalSpend = items.reduce((s, r) => s + (r.is_gift ? 0 : (r.purchase_price != null ? Number(r.purchase_price) : 0)), 0);
  const netUplift = Math.round(total) - Math.round(totalSpend);
  const knownCount = items.filter(r => r.is_gift === true || (r.purchase_price !== null && r.purchase_price !== undefined)).length;
  const canonOwned = new Set(items.filter(r => r.slug).map(r => r.slug)).size;
  const genres = useV2Memo(() => Array.from(new Set(items.map(r => r.genre))).sort(), [items]);
  const mostValuable = items.reduce((m, r) => (!m || r.value > m.value ? r : m), null);
  const itemsById = useV2Memo(() => { const m = {}; items.forEach(r => { m[r.id] = r; }); return m; }, [items]);

  // Portfolio block (Part 4). Week-over-week movement from the SAME live
  // snapshots the tiles use (Part 3), so the two never diverge. Weekly delta =
  // sum(latest live print) - sum(previous week's live print) over items held in
  // BOTH weeks; an item with a single live print (added this week / first print)
  // has no previous week and is excluded, so new records never count as gains.
  // Movers are the same week-over-week change, top 3 gainers + top 1 faller.
  const portfolio = useV2Memo(() => {
    if (!liveSnaps) return null;
    let weekDelta = 0, hasPrev = false;
    const moved = [];
    Object.keys(liveSnaps).forEach(id => {
      const list = liveSnaps[id];
      if (!list || list.length < 2) return;             // added this week / single print
      const latest = list[list.length - 1], prev = list[list.length - 2];
      const change = latest.v - prev.v;
      hasPrev = true;
      weekDelta += change;
      if (Math.round(change) !== 0) moved.push({ id, change });
    });
    const gainers = moved.filter(m => m.change > 0).sort((a, b) => b.change - a.change).slice(0, 3);
    const faller = moved.filter(m => m.change < 0).sort((a, b) => a.change - b.change).slice(0, 1);
    return { hasPrev, weekDelta, movedCount: moved.length, movers: gainers.concat(faller) };
  }, [liveSnaps]);

  // DAILY portfolio delta for the value-bar headline — computed from
  // valuation_snapshots_daily via the SAME helper The Index panel uses, so the
  // "today" figure on the bar and inside the panel never disagree. null while
  // loading; { todayDelta } is null until there are 2 real daily prints (from
  // tomorrow's capture on), which renders as the "first move tomorrow" state.
  const [dailyPort, setDailyPort] = useV2State(null);
  useV2Effect(() => {
    let active = true;
    if (!user || !user.id || !window.BBRDailyIndex) { setDailyPort(null); return; }
    window.BBRDailyIndex.fetchDailySnaps(user.id)
      // Keep byItem, don't discard it. The portfolio total was all this used to
      // take; the movers need the same rows and re-fetching them for a second
      // consumer would double the query for no reason.
      .then((byItem) => { if (active) setDailyPort({ port: window.BBRDailyIndex.buildPortfolio(byItem), byItem }); })
      .catch(() => { if (active) setDailyPort({ port: null, byItem: null }); });
    return () => { active = false; };
  }, [user && user.id, items.length]);

  // The Index's two most useful findings, computed here so they can sit ON the page
  // instead of behind the reveal button. The panel still exists and still holds the
  // chart, the full mover list and every sell signal — this is its headline, not a
  // replacement. A panel behind a small button is invisible on a phone, and this is
  // the best data the product has.
  const tiItemMeta = useV2Memo(() => {
    const m = {};
    (items || []).forEach((r) => { m[r.id] = { title: r.title, artist: r.artist }; });
    return m;
  }, [items]);
  const tiTopMover = useV2Memo(() => {
    const byItem = dailyPort && dailyPort.byItem;
    if (!byItem || !window.BBRDailyIndex) return null;
    const movers = window.BBRDailyIndex.buildMovers(byItem, tiItemMeta);
    // Sorted by absolute move already; a sub-£1 "mover" is noise, not news.
    return movers.length && Math.abs(movers[0].delta) >= 1 ? movers[0] : null;
  }, [dailyPort, tiItemMeta]);
  const tiSells = useV2Memo(
    () => (window.BBRDailyIndex ? window.BBRDailyIndex.buildSellSignals(items) : []),
    [items]
  );

  // "New report" dot: show only when there's a real, non-flat move today AND the
  // member hasn't opened The Index yet today. Pulls them in on days worth looking.
  const tiTodayStr = new Date().toISOString().slice(0, 10);
  const tiHasMoveToday = !!(dailyPort && dailyPort.port && dailyPort.port.todayDelta && dailyPort.port.todayDelta.dir !== "flat");
  const showIndexDot = tiHasMoveToday && indexSeen !== tiTodayStr;
  const openIndex = () => {
    setIndexOpen(true);
    v2LsSet("ti_seen", tiTodayStr);
    setIndexSeen(tiTodayStr);
  };

  const view = useV2Memo(() => {
    let list = items.filter(r => {
      if (filterFolder === "unfiled" && r.folder_id) return false;
      if (filterFolder !== "all" && filterFolder !== "unfiled" && r.folder_id !== filterFolder) return false;
      if (filterGenre !== "all" && r.genre !== filterGenre) return false;
      if (search && !((r.title + " " + r.artist).toLowerCase().includes(search.toLowerCase()))) return false;
      return true;
    });
    // Native Array.prototype.sort is stable, so equal keys keep their existing
    // relative order (which is date_added desc from the DB load).
    const cmp = {
      value: (a, b) => b.value - a.value,
      artistFirst: (a, b) => v2cmpStr(v2ArtistFirst(a.artist), v2ArtistFirst(b.artist)),
      artistLast: (a, b) => v2cmpStr(v2ArtistLast(a.artist), v2ArtistLast(b.artist)),
      yearNew: (a, b) => v2cmpYear(a, b, "desc"),
      yearOld: (a, b) => v2cmpYear(a, b, "asc"),
      added: (a, b) => String(b.date_added).localeCompare(String(a.date_added)),
    }[sortKey] || ((a, b) => b.value - a.value);
    return list.slice().sort(cmp);
  }, [items, filterGenre, filterFolder, search, sortKey]);

  const empty = items.length === 0 && tab === "collection";

  // The value-bar movement figure is the SAME week-over-week delta the movers
  // strip uses (portfolio.weekDelta), so the headline, the movers, and the
  // weekly-snapshot cadence all speak in one window ("this week"). The per-tile
  // badge stays on its own 30-day window (Part 3) — a longer look for a single
  // record — which is why the header reads from `portfolio`, not `liveDeltas`.

  // Jump-nav sections: only what actually renders for the current tab + size.
  const navSections = useV2Memo(() => {
    if (tab !== "collection") return [{ id: "sec-shelf", label: "Wishlist" }];
    const s = [{ id: "sec-shelf", label: "Shelf" }];
    if (window.CollectionDNA) s.push({ id: "sec-dna", label: "Collection DNA" });
    if (items.length > 0 && window.NextRecord) s.push({ id: "sec-next", label: "Next Record" });
    if (items.length > 1 && window.CollectionDashboard_v2) s.push({ id: "sec-numbers", label: "The Numbers" });
    return s;
  }, [tab, items.length]);

  // Scroll-spy: the active section is the last one whose top has passed under
  // the sticky nav. rAF-throttled; re-armed whenever the section set changes.
  const [activeSection, setActiveSection] = useV2State("sec-shelf");
  useV2Effect(() => {
    const ids = navSections.map(s => s.id);
    let raf = 0;
    function measure() {
      raf = 0;
      const offset = 140;   // ~ site nav + jump nav height
      let cur = ids[0];
      for (let i = 0; i < ids.length; i++) {
        const el = document.getElementById(ids[i]);
        if (el && el.getBoundingClientRect().top <= offset) cur = ids[i];
      }
      setActiveSection(cur);
    }
    function onScroll() { if (!raf) raf = requestAnimationFrame(measure); }
    window.addEventListener("scroll", onScroll, { passive: true });
    measure();
    return () => { window.removeEventListener("scroll", onScroll); if (raf) cancelAnimationFrame(raf); };
  }, [navSections]);

  function jumpTo(id) {
    const el = document.getElementById(id);
    if (el) { try { el.scrollIntoView({ behavior: "smooth", block: "start" }); } catch (e) { el.scrollIntoView(); } }
    setActiveSection(id);
  }

  function openAdd() { setAddOpen(true); }

  // Swap one full-screen overlay for another without their history entries
  // racing. Every overlay uses BBR_useModalBack, which pushes a history entry
  // on mount and pops it with history.back() on an IN-APP close (× / backdrop).
  // Closing one overlay and opening the next in the SAME tick let the closer's
  // async back() consume the opener's freshly-pushed entry; the opener's own
  // popstate listener then read that as a Back and closed itself, which is why
  // tapping "Import a collection" used to flash the importer open and dump the
  // user back on the collection. So close first, wait for the back()-driven
  // popstate to settle, THEN open. When viaBack is true the entry was already
  // popped by the OS Back press, so there is nothing to wait for: open now.
  function swapOverlay(closeFn, openFn, viaBack) {
    closeFn();
    if (viaBack) { openFn(); return; }
    let done = false;
    const go = () => { if (done) return; done = true; window.removeEventListener("popstate", go); openFn(); };
    window.addEventListener("popstate", go);
    setTimeout(go, 500); // backstop if no popstate fires (e.g. the entry was already gone)
  }

  // From the add sheet's "Import a collection" card: close the add sheet, then
  // open the importer once the add sheet's history entry has been consumed.
  function openImportFromAdd() {
    setImportFromAdd(true);
    swapOverlay(() => setAddOpen(false), () => setImportOpen(true), false);
  }

  // Closing the importer. If it was opened from the add sheet, step back INTO
  // the add sheet (acceptance: Back returns to the add flow, not the collection);
  // otherwise (toolbar / empty-state / Discogs return) just close to collection.
  function closeImporter(viaBack) {
    const fromAdd = importFromAdd;
    setImportFromAdd(false);
    setDiscogsReturn(null);
    setStartDiscogs(false);
    if (fromAdd) swapOverlay(() => setImportOpen(false), () => setAddOpen(true), viaBack);
    else setImportOpen(false);
  }

  // The importer's own "Done" (after a successful import) always lands on the
  // collection so the freshly imported records are visible, never back to add.
  function finishImporter() {
    setImportFromAdd(false);
    setDiscogsReturn(null);
    setStartDiscogs(false);
    setImportOpen(false);
  }

  // Empty-state "Connect Discogs": open the importer and kick straight into the
  // OAuth handshake (reuses the importer's autoDiscogs path — connectDiscogs()
  // redirects an un-connected user to Discogs, or pulls a connected one).
  function openDiscogs() { setImportFromAdd(false); setDiscogsReturn(null); setStartDiscogs(true); setImportOpen(true); }

  // Tab bar lives below the hero, so switching tabs while scrolled down (e.g.
  // deep in a long collection) left the freshly-shown wishlist starting below
  // the fold. Scroll the bar up under the nav so the new tab opens at the top.
  const tabBarRef = React.useRef(null);
  function switchTab(t) { setTab(t); }
  // M4 (QA, real-device iOS Safari): two compounding bugs reproduced in an
  // isolated repro. (1) Measuring tabBarRef synchronously inside switchTab
  // read the layout BEFORE React removed the Collection DNA panel (shown only
  // on the collection tab, between the hero and the tab bar) — fixed by
  // moving the measurement into an effect (runs after the new tab's DOM has
  // committed) + one rAF (after the browser has painted/reflowed it). (2)
  // Even with an accurate measurement, pinning the tab bar at y=72 is
  // mathematically impossible when the destination tab's content is short
  // (e.g. a wishlist with few items) — the desired scrollTo Y exceeds the
  // page's actual max scroll, so the browser clamps it to the document's
  // bottom, which for a short page IS "the bottom of the page" — exactly
  // QA's "wishlist opens at the bottom". When that's the case, scroll to the
  // very top instead: a page too short to pin the tab bar needs no offset to
  // show its content anyway. Skip the very first run so mount doesn't scroll.
  const tabSwitchReady = React.useRef(false);
  useV2Effect(() => {
    if (!tabSwitchReady.current) { tabSwitchReady.current = true; return; }
    const el = tabBarRef.current;
    if (!el) return;
    requestAnimationFrame(() => {
      const top = el.getBoundingClientRect().top;
      const off = window.pageYOffset || 0;
      const desired = top + off - 112;   // clear the site nav + sticky jump nav
      const maxScroll = Math.max(0, document.body.scrollHeight - window.innerHeight);
      const y = desired > maxScroll ? 0 : Math.max(0, desired);
      window.scrollTo(0, y);
    });
  }, [tab]);

  // On (re)load, always open at the top. iOS Safari's scroll restoration can
  // land a fresh load part-way down the page — and with the sticky jump-nav that
  // leaves the headline value hidden behind the bar. Take restoration into our
  // own hands and pin to the top on mount. (overflow-anchor:none in CSS stops the
  // delayed level-up banner from nudging the scroll as it appears/dismisses.)
  React.useEffect(() => {
    try { if ("scrollRestoration" in history) history.scrollRestoration = "manual"; } catch (e) {}
    window.scrollTo(0, 0);
  }, []);

  // Wishlist "Got it": move the record into the verified collection. The
  // wishlist lives in the legacy `records` store, so map the resolved record
  // onto a collection_items row (value left unset -> the reprice job fills it,
  // same as an import), drop it from the wishlist, and jump to the collection
  // so the move is visible.
  async function moveWishToCollection(wr) {
    if (!wr || !store.userId) return;
    const al = wr.album || {};
    const pr = wr.pressing || {};
    const yr = typeof al.year === "number" ? al.year : parseInt(al.year, 10);
    const fmt = Array.isArray(pr.format) ? pr.format.filter(Boolean).join(" · ") : (pr.format || null);
    const grade = (window.BBR_import && window.BBR_import.parseGrade(wr.condition)) || null;
    const item = {
      slug: al.slug || null,
      discogs_release_id: pr.discogs_release_id || pr.releaseId || null,
      barcode: null,
      artist: al.artist || null,
      title: al.title || null,
      year: Number.isFinite(yr) ? yr : null,
      genre: al.genre || null,
      media_condition: grade,
      sleeve_condition: null,
      label: (pr.label && pr.label !== "Unknown pressing") ? pr.label : null,
      catalogue_no: (pr.catno && pr.catno !== "—") ? pr.catno : null,
      format: fmt || null,
      notes: null,
      est_value: null,
      value_currency: "GBP",
      verification_level: "manual",
    };
    try {
      await store.addCollectionItemV2(item);
      store.removeFromWishlist(wr.id);
      switchTab("collection");
    } catch (e) { console.error("[BBR] move-to-collection failed", e); }
  }

  return (
    <div className="mc">
      <CollectorLevelUp count={items.length} />
      {/* JUMP NAV — sticky section switcher (Option B) */}
      <CollectionNav
        tab={tab} sections={navSections} activeId={activeSection}
        onJump={jumpTo}
        onTab={() => switchTab(tab === "collection" ? "wishlist" : "collection")}
      />

      {/* VALUE BAR — one headline value + one 30-day movement + counts. Settings
          (visibility, invite) sit to the side, not inside the headline figure. */}
      <div className="mc-valuebar" id="sec-top">
        <div className="mc-vb-main">
          <span className="mc-eyebrow">Estimated collection value</span>
          <div className="mc-vb-figure">
            <div className="mc-vb-total"><span className="cur">£</span><AnimatedMoney value={total} /></div>
            {items.length > 0 && dailyPort && dailyPort.port && (
              dailyPort.port.todayDelta
                ? (dailyPort.port.todayDelta.dir === "flat" || Math.round(Math.abs(dailyPort.port.todayDelta.d)) === 0
                    ? <span className="mc-vb-move flat">No change<small>today</small></span>
                    : <span className={"mc-vb-move " + (dailyPort.port.todayDelta.dir === "up" ? "up" : "down")}>
                        <span className="mc-arrow">{dailyPort.port.todayDelta.dir === "up" ? "▲" : "▼"}</span>£{Math.abs(Math.round(dailyPort.port.todayDelta.d)).toLocaleString("en-GB")}<small>today</small>
                      </span>)
                : <span className="mc-vb-move flat">Tracking live<small>first move tomorrow</small></span>
            )}
          </div>
          {items.length > 0 && window.TheIndex && (
            <button type="button" className={"ti-reveal" + (showIndexDot ? " has-new" : "")} onClick={openIndex}
              title="Open The Index — your collection as a daily portfolio">
              {showIndexDot && <span className="ti-reveal-dot" aria-hidden="true" />}
              <svg className="ti-reveal-ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M3 17l5-5 4 3 6-7" /><path d="M18 8h3v3" />
              </svg>
              <span className="ti-reveal-t">{showIndexDot ? "Your collection moved today" : "View today’s report"}</span>
              <span className="ti-reveal-go" aria-hidden="true">→</span>
            </button>
          )}
          {/* The Index, promoted. Two lines of the real thing, tappable straight
              into the full panel. Renders only what it actually has: no mover, no
              mover line; no sell signal, no sell line; both absent and the strip
              itself does not appear rather than showing an empty shell. */}
          {items.length > 0 && window.TheIndex && (tiTopMover || tiSells.length > 0) && (
            <div className="ti-strip">
              {tiTopMover && (
                <button type="button" className="ti-strip-row" onClick={openIndex}>
                  <span className="ti-strip-k">Biggest mover · 7 days</span>
                  <span className="ti-strip-v">
                    <span className="ti-strip-title">{tiTopMover.title}</span>
                    <span className={"ti-strip-d " + (tiTopMover.delta >= 0 ? "up" : "down")}>
                      {tiTopMover.delta >= 0 ? "▲" : "▼"} £{Math.abs(tiTopMover.delta).toFixed(Math.abs(tiTopMover.delta) < 10 ? 2 : 0)}
                      {tiTopMover.pct != null ? <small>{(tiTopMover.pct >= 0 ? "+" : "") + tiTopMover.pct.toFixed(1)}%</small> : null}
                    </span>
                  </span>
                </button>
              )}
              {tiSells.length > 0 && (
                <button type="button" className="ti-strip-row" onClick={openIndex}>
                  <span className="ti-strip-k">Worth more than you paid</span>
                  <span className="ti-strip-v">
                    <span className="ti-strip-title">
                      {tiSells.length} {tiSells.length === 1 ? "record is" : "records are"} up 25%+
                    </span>
                    <span className="ti-strip-d up">
                      ▲ £{Math.round(tiSells.reduce((s, x) => s + x.gain, 0)).toLocaleString("en-GB")}
                    </span>
                  </span>
                </button>
              )}
            </div>
          )}
          {items.length > 0 ? (
            <div className="mc-vb-chips">
              <CollectorBadge count={items.length} />
              <span className="mc-chip">{items.length} records</span>
              <span className="mc-chip">{canonOwned} / 100 of the Hundred</span>
              <span className="mc-chip">{genres.length} genres</span>
            </div>
          ) : (
            <div className="mc-vb-chips"><span className="mc-chip">Scan your first record to begin</span></div>
          )}
          {items.length > 0 && knownCount > 0 && (
            <div className={"mc-vb-uplift " + (netUplift > 0 ? "pos" : netUplift < 0 ? "neg" : "zero")}>
              <span className="mc-vb-uplift-v">{netUplift > 0 ? "+" : netUplift < 0 ? "−" : ""}£{Math.abs(netUplift).toLocaleString("en-GB")}</span>
              <span className="mc-vb-uplift-k">vs what you paid</span>
              <span className="mc-vb-uplift-cap">across {knownCount} of {items.length} priced records · refreshed daily</span>
            </div>
          )}
        </div>
        <div className="mc-vb-side">
          <VisibilityToggle />
          {items.length > 0 && (
            <button type="button" className="mc-invite mc-appraise" onClick={() => setAppraisalOpen(true)} title="Download a dated, print-ready valuation for an insurer, probate or a claim">
              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h6"/>
              </svg>
              Appraisal
            </button>
          )}
          <button type="button" className="mc-invite" onClick={() => setInviteOpen(true)}>
            <svg width="15" height="15" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <circle cx="13.5" cy="3.8" r="2.3" /><circle cx="4.5" cy="9" r="2.3" /><circle cx="13.5" cy="14.2" r="2.3" />
              <path d="M6.5 7.8l5 -2.8M6.5 10.2l5 2.8" />
            </svg>
            Invite a friend
          </button>
        </div>
      </div>

      {/* TABS + CONTROLS */}
      <div className="mc-bar" ref={tabBarRef} id="sec-shelf">
        <div className="mc-tabs">
          <button className={"mc-tab " + (tab === "collection" ? "active" : "")} onClick={() => switchTab("collection")}>Collection<span className="ct">{items.length}</span></button>
          <button className={"mc-tab " + (tab === "wishlist" ? "active" : "")} onClick={() => switchTab("wishlist")}>Wishlist<span className="ct">{resolvedWish.length}</span></button>
        </div>
        <div className="mc-controls">
          {tab === "collection" && (
            <div className="mc-search">
              <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="7" cy="7" r="5" /><path d="M11 11l3.5 3.5" /></svg>
              <input placeholder="Search your collection…" value={search} onChange={e => setSearch(e.target.value)} />
            </div>
          )}
          {tab === "collection" && (
            <div className="mc-select">
              <select value={filterGenre} onChange={e => setFilterGenre(e.target.value)}>
                <option value="all">All genres</option>
                {genres.map(g => <option key={g} value={g}>{g}</option>)}
              </select>
            </div>
          )}
          {tab === "collection" && (
            <div className="mc-select">
              <select value={sortKey} onChange={e => setSortKey(e.target.value)}>
                <option value="value">Sort: Value</option>
                <option value="artistFirst">Sort: Artist (first name)</option>
                <option value="artistLast">Sort: Artist (surname)</option>
                <option value="yearNew">Sort: Year (newest)</option>
                <option value="yearOld">Sort: Year (oldest)</option>
                <option value="added">Sort: Date added</option>
              </select>
            </div>
          )}
          {tab === "collection" && (
            <button className="mc-import" onClick={() => setImportOpen(true)} title="Import a whole collection from a CSV (Discogs, CLZ, or your own spreadsheet) or by connecting Discogs">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>
              Import collection
            </button>
          )}
          <button className="mc-add" onClick={() => tab === "wishlist" ? setWishAdd({ intent: "wishlist" }) : openAdd()}>
            <span className="plus">+</span> {tab === "wishlist" ? "Add to wishlist" : "Add record"}
          </button>
        </div>
      </div>

      {/* FOLDER BAR — filters the grid below; sort/search apply within it */}
      {tab === "collection" && !empty && (
        <V2FolderBar folders={folders} items={items} filterFolder={filterFolder} setFilterFolder={setFilterFolder} />
      )}

      {/* COLLECTION GRID */}
      {tab === "collection" && (empty ? (
        <div className="mc-empty">
          <div className="mc-empty-badge">First record · 20 seconds</div>
          <h3>Add your first record</h3>
          <p>Log one record and The Lead-In values it, tracks how it moves week to week, and shows where your shelf sits against the hundred greatest ever pressed.</p>
          <ol className="mc-empty-steps">
            <li onClick={openAdd} role="button" tabIndex={0} onKeyDown={e => { if (e.key === "Enter" || e.key === " ") openAdd(); }}>
              <span className="mc-es-ico" aria-hidden="true">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"><path d="M3 5v14M7 5v14M11 5v14M15 5v14M19 5v14"/></svg>
              </span>
              <span className="mc-es-body"><b>Scan the barcode</b><small>Point your phone at the sleeve.</small></span>
              <span className="mc-es-go" aria-hidden="true">→</span>
            </li>
            <li onClick={openAdd} role="button" tabIndex={0} onKeyDown={e => { if (e.key === "Enter" || e.key === " ") openAdd(); }}>
              <span className="mc-es-ico" aria-hidden="true">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8a2 2 0 0 1 2-2h1.5l1-1.5h5l1 1.5H21a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" transform="translate(-1 0)"/><circle cx="11" cy="12.5" r="3.2"/></svg>
              </span>
              <span className="mc-es-body"><b>Snap the cover</b><small>Photograph the front — we'll identify it.</small></span>
              <span className="mc-es-go" aria-hidden="true">→</span>
            </li>
            <li onClick={() => setImportOpen(true)} role="button" tabIndex={0} onKeyDown={e => { if (e.key === "Enter" || e.key === " ") setImportOpen(true); }}>
              <span className="mc-es-ico" aria-hidden="true">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>
              </span>
              <span className="mc-es-body"><b>Import a collection</b><small>Connect Discogs or drop a CSV export.</small></span>
              <span className="mc-es-go" aria-hidden="true">→</span>
            </li>
          </ol>
          <div className="mc-empty-cta">
            <button className="mc-btn solid" onClick={openAdd}><span style={{ fontSize: 14 }}>+</span> Add your first record</button>
            <button className="mc-btn discogs" onClick={openDiscogs}>
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="2.4"/></svg>
              Connect Discogs
            </button>
          </div>
          <p className="mc-empty-discogs-hint">Already collect on Discogs? <button type="button" className="mc-linkbtn" onClick={openDiscogs}>Import your whole shelf in one tap</button> — or <button type="button" className="mc-linkbtn" onClick={() => setImportOpen(true)}>upload a CSV</button>.</p>
          <p className="mc-empty-payoff">Add one and you get a <b>live valuation</b> straight away, plus your spot on the <b>leaderboard</b>. Your <b>Collection DNA</b> unlocks at 20.</p>
        </div>
      ) : view.length === 0 ? (
        <div className="mc-folder-empty">
          {filterFolder === "unfiled"
            ? "No unfiled records — everything's in a folder."
            : (filterFolder !== "all" && !(search || filterGenre !== "all"))
              ? "No records in this folder yet"
              : "No records match your search."}
        </div>
      ) : (
        <div className="mc-grid">
          {view.map(r => <V2Card key={r.id} r={r} liveDelta={liveDeltas[r.id]} onRemove={(id) => store.removeCollectionItemV2(id)} onOpen={(rec) => setDetailId(rec.id)} />)}
        </div>
      ))}

      {/* ===== INSIGHTS — everything analytical lives here, below the shelf, so
          the records lead. Each fact appears once (DNA = identity / character;
          The Numbers = the quantitative breakdowns). The jump-nav links straight
          to each section. ===== */}
      {tab === "collection" && (
        <div className="mc-insights">
          {window.CollectionDNA && (
            <div id="sec-dna" className="mc-insight"><CollectionDNA items={items} /></div>
          )}

          {items.length > 0 && window.NextRecord && (
            <div id="sec-next" className="mc-insight"><NextRecord items={items} albums={albums} /></div>
          )}

          {items.length > 1 && window.CollectionDashboard_v2 && (
            <div id="sec-numbers" className="mc-insight">
              <CollectionDashboard_v2 items={items} albums={albums} total={total} canonOwned={canonOwned} />
            </div>
          )}

          {/* Recent movers — the individual records that changed, from the same
              live snapshots as the tile deltas. Aggregate movement now lives in
              the value bar; this is the per-record detail. */}
          {!empty && portfolio && portfolio.movedCount >= 2 && (
            <div className="mc-insight mc-portfolio">
              <div className="mcp-movers">
                <div className="mcp-movers-h">Recent movers</div>
                <div className="mcp-movers-list">
                  {portfolio.movers.map(m => {
                    const rec = itemsById[m.id];
                    if (!rec) return null;
                    const up = m.change > 0;
                    return (
                      <button key={m.id} className="mcp-mover" onClick={() => setDetailId(m.id)}>
                        <span className="mcp-mover-cover"><Sleeve album={rec.album} size={40} /></span>
                        <span className="mcp-mover-txt">
                          <span className="mcp-mover-t">{rec.title}</span>
                          <span className="mcp-mover-a">{rec.artist}</span>
                        </span>
                        <span className={"mc-delta " + (up ? "up" : "down")}>
                          <span className="mc-arrow">{up ? "▲" : "▼"}</span>£{Math.abs(Math.round(m.change)).toLocaleString("en-GB")}
                        </span>
                      </button>
                    );
                  })}
                </div>
              </div>
            </div>
          )}

          {/* BEST DIG: owner-only celebration + share card (Part 6). Cost data
              shown here only; this view is never a public/shared collection. */}
          {!empty && window.BestDig && <div className="mc-insight"><BestDig items={items} /></div>}

          {/* Cross-links: give the collection page onward paths (leaderboard,
              deep dives, the ranked Hundred) to deepen the session. */}
          {window.ExploreMore && <ExploreMore from="collection" exclude={["collection"]} />}
        </div>
      )}

      {/* WISHLIST (unchanged data + card) */}
      {tab === "wishlist" && (resolvedWish.length === 0 ? (
        <div className="mc-empty">
          <h3>Nothing on the wishlist yet</h3>
          <p>Queue up the records you're hunting for.</p>
          <div className="mc-empty-cta"><button className="mc-btn solid" onClick={() => setWishAdd({ intent: "wishlist" })}><span style={{ fontSize: 14 }}>+</span> Add to wishlist</button></div>
        </div>
      ) : (
        <div className="mc-grid">
          {resolvedWish.map(r => <WishCard key={r.id} r={r} liveDelta={wishDeltas[r.id]} moved={wishMoved.has(r.id)} onMove={moveWishToCollection} onRemove={(id) => store.removeFromWishlist(id)} />)}
        </div>
      ))}

      {/* ADD FLOWS */}
      {addOpen && (
        <MyCollectionAdd_v2
          albums={albums}
          acceptedTerms={store.acceptedTerms}
          onAcceptTerms={() => store.acceptTerms()}
          onAdd={async (item) => { await store.addCollectionItemV2(item); setAddOpen(false); }}
          onImport={openImportFromAdd}
          onClose={() => setAddOpen(false)}
        />
      )}
      {importOpen && window.CollectionImport && (
        <CollectionImport
          albums={albums}
          autoDiscogs={discogsReturn === "connected" || startDiscogs}
          discogsError={discogsReturn === "error"}
          onClose={closeImporter}
          onDone={finishImporter}
        />
      )}
      {inviteOpen && window.InviteFriend && (
        <InviteFriend onClose={() => setInviteOpen(false)} />
      )}
      {indexOpen && window.TheIndex && (
        <TheIndex user={user} items={items} onClose={() => setIndexOpen(false)} />
      )}
      {appraisalOpen && window.AppraisalExport && (
        <AppraisalExport user={user} items={items} onClose={() => setAppraisalOpen(false)} />
      )}
      {wishAdd && (
        <AddRecordFlow
          albums={albums}
          intent="wishlist"
          onAdd={(rec) => { store.addToWishlist(rec); setWishAdd(null); }}
          onClose={() => setWishAdd(null)}
        />
      )}
      {/* ITEM DETAIL / EDIT — item looked up live by id so it tracks store edits */}
      {detailId != null && window.CollectionItemDetail_v2 && (() => {
        const it = items.find(r => r.id === detailId);
        if (!it) return null;
        return (
          <CollectionItemDetail_v2
            item={it}
            folders={folders}
            onSave={async (id, patch) => { await store.updateCollectionItemV2(id, patch); }}
            onClose={() => setDetailId(null)}
          />
        );
      })()}
    </div>
  );
}

Object.assign(window, { CollectionAppV2 });
