// ===========================================================
// Leaderboards_v2 + PublicCollectionV2.
// Public (works logged-out). Computed client-side from the public
// view + profiles so each row can show the collector's CROWN JEWEL
// cover and "collecting since" date. (Small dataset; if it grows,
// move aggregation back to the leaderboard_totals() function.)
// Reuses globals: Sleeve, AnimatedMoney.
// ===========================================================
const { useState: useLbState, useEffect: useLbEffect, useMemo: useLbMemo } = React;

function lbGbp(n) { return "£" + Math.round(Number(n) || 0).toLocaleString("en-GB"); }
function lbGrade(code) { return code ? code.replace("_PLUS", "+") : "—"; }
function lbSince(iso) {
  if (!iso) return null;
  const d = String(iso).slice(0, 10).split("-");
  const mo = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][parseInt(d[1], 10) - 1] || "";
  return mo + " " + d[0];
}
function lbAlbumFor(r, albums) {
  if (!r) return null;
  return r.slug ? (albums.find(a => a.slug === r.slug) || { title: r.title, artist: r.artist, year: r.year, rank: -1, slug: r.slug })
                : { title: r.title, artist: r.artist, year: r.year, rank: -1, slug: null };
}
function CrownIcon({ size = 13 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style={{ verticalAlign: "-1px" }}>
      <path d="M2 8l4.5 3.5L12 4l5.5 7.5L22 8l-2 11H4L2 8z" />
    </svg>
  );
}

function lbMoverChip(delta) {
  if (delta == null) return null;
  if (delta === 0) return <span className="lb-mv flat" title="No change since last week">&ndash;</span>;
  if (delta > 0) return <span className="lb-mv up" title={"Up " + delta + " since last week"}>&#9650; {delta}</span>;
  return <span className="lb-mv down" title={"Down " + (-delta) + " since last week"}>&#9660; {-delta}</span>;
}

// Count-up number that animates from 0 when scrolled into view (reuses the
// homepage HdCountUp when present; falls back to a static formatted number).
function LbNum({ value }) {
  return window.HdCountUp ? <HdCountUp value={Math.round(Number(value) || 0)} /> : <>{(Math.round(Number(value) || 0)).toLocaleString("en-GB")}</>;
}

// Cover art for non-canon (manual) records, fetched once from /api/cover
// (Discogs, with an iTunes fallback) and cached in localStorage so a record is
// only ever looked up once. Canon picks use their on-disk /covers file instead.
const LB_COVER_CACHE = (() => {
  try { return JSON.parse(window.localStorage.getItem("bbr_cover_cache") || "{}"); } catch (e) { return {}; }
})();
function lbCoverKey(r) { return ((r && r.artist) || "") + "|" + ((r && r.title) || ""); }
function lbFetchCover(r) {
  const key = lbCoverKey(r);
  if (key in LB_COVER_CACHE) return Promise.resolve(LB_COVER_CACHE[key]);
  const q = new URLSearchParams({ artist: r.artist || "", title: r.title || "" });
  if (r.year) q.set("year", String(r.year));
  if (r.discogs_release_id) q.set("release_id", String(r.discogs_release_id));
  return fetch("/api/cover?" + q.toString())
    .then(res => (res.ok ? res.json() : null))
    .then(d => {
      const url = (d && d.coverUrl) || null;
      LB_COVER_CACHE[key] = url; // cache misses too, so we don't refetch them
      try { window.localStorage.setItem("bbr_cover_cache", JSON.stringify(LB_COVER_CACHE)); } catch (e) {}
      return url;
    })
    .catch(() => null);
}

// One human sentence for every way the board can fail to load. The cause goes
// to console.error; this is what a reader sees.
const LB_LOAD_ERR = "We couldn't load the standings just now. Refresh in a moment and they should be back.";

// The board tabs, in display order. Shared by the tablist and its keyboard nav.
const LB_BOARDS = [
  ["value", "Total value"],
  ["genre", "By genre"],
  ["canon", "The Hundred"],
  ["champions", "Genre champions"],
  ["rarest", "Rarest finds"],
];

// ---- signed-out detection, without a new prop -------------------------------
// /leaderboards and /collector/:id are both anon-accessible, and both need to
// know whether to offer a join CTA. App.jsx owns these components' call
// signatures, so instead of asking for a `user` prop we read the session off
// the client data/supabase.js already exposes. `currentUserId` (which the board
// already receives) is an immediate positive signal; otherwise we wait for the
// session to resolve, so a member never sees a flash of "join us" copy.
// Returns true ONLY once we are sure nobody is signed in.
function useLbSignedOut(currentUserId) {
  const [state, setState] = useLbState(currentUserId ? "in" : "unknown");
  useLbEffect(() => {
    if (currentUserId) { setState("in"); return; }
    // getSession() reads the locally stored session — no network round trip on
    // every anonymous page view, unlike BBR_auth.current() / getUser().
    const c = window.BBR_supabase;
    if (!c || !c.auth || !c.auth.getSession) { setState("out"); return; }
    let cancelled = false;
    c.auth.getSession().then(
      ({ data }) => { if (!cancelled) setState(data && data.session ? "in" : "out"); },
      () => { if (!cancelled) setState("out"); }   // no session to be had
    );
    return () => { cancelled = true; };
  }, [currentUserId]);
  return state === "out";
}

// ---- the join card ----------------------------------------------------------
// Both of these pages were dead ends. A shared link dropped a stranger onto
// someone else's four-thousand-pound shelf, or onto the full board, and offered
// exactly one control: "← Leaderboards". This is the way out — editorial, not a
// growth banner, and it does not invent anything: every figure is read off the
// board that is already on screen. Uses window.__bbrRequireAuth (App.jsx exposes
// it for precisely this) so nothing here needs a new prop.
function lbJoin(intent) {
  // Signed out: requireAuth opens the gate and returns false. Signed in: it
  // returns true and does nothing, so we take them to their own collection —
  // which is where "add a record" actually happens. Same button, both states.
  const signedIn = window.__bbrRequireAuth ? window.__bbrRequireAuth(intent || "collection") : true;
  if (signedIn && window.__nav && window.__nav.goCollection) window.__nav.goCollection();
}

function LbJoinCard({ eyebrow, heading, body, cta, intent }) {
  return (
    <div className="hd-comm-card hd-proof" style={{ margin: "28px 0 8px", textAlign: "left" }}>
      <div className="hd-proof-tag">{eyebrow}</div>
      <h3 className="lb-title" style={{ fontSize: "22px", margin: "0 0 8px" }}>{heading}</h3>
      <p className="lb-sub" style={{ margin: "0 0 16px" }}>{body}</p>
      <button className="mc-btn solid" onClick={() => lbJoin(intent)}>{cta}</button>
    </div>
  );
}

function Leaderboards_v2({ albums, currentUserId, onViewCollection }) {
  const [items, setItems] = useLbState(null);             // PUBLIC-only items (covers, showcases, genres)
  const [totals, setTotals] = useLbState(null);           // leaderboard_totals() — ALL collectors, identity gated (ranking spine)
  const [genreTotals, setGenreTotals] = useLbState(null); // per-genre totals incl. private (anonymised)
  const [snaps, setSnaps] = useLbState([]);
  const [board, setBoard] = useLbState("value"); // value | genre | canon | champions | rarest
  const [genre, setGenre] = useLbState("");
  const [err, setErr] = useLbState("");
  const [covers, setCovers] = useLbState({}); // cover-art key -> url|null (fetched for non-canon records)
  const coverReq = React.useRef(new Set());
  const signedOut = useLbSignedOut(currentUserId);
  const tabsRef = React.useRef(null);

  // Tablist keyboard contract: arrows move between boards (and activate, which
  // is what this strip has always done on click), Home/End jump to the ends.
  function onTabKey(e) {
    if (["ArrowRight", "ArrowLeft", "Home", "End"].indexOf(e.key) < 0) return;
    e.preventDefault();
    const i = LB_BOARDS.findIndex(b => b[0] === board);
    const last = LB_BOARDS.length - 1;
    const to = e.key === "ArrowRight" ? (i >= last ? 0 : i + 1)
             : e.key === "ArrowLeft" ? (i <= 0 ? last : i - 1)
             : e.key === "Home" ? 0 : last;
    setBoard(LB_BOARDS[to][0]);
    const el = tabsRef.current && tabsRef.current.querySelector("#lb-tab-" + LB_BOARDS[to][0]);
    if (el) el.focus();   // also scrolls the strip, which is how the hidden-overflow tabs get reached
  }

  useLbEffect(() => {
    let cancelled = false;
    // No backend wired up (config/CDN failure) — show an empty board rather
    // than throwing on the missing client, like every other data view does.
    if (!window.BBR_supabase) { setItems([]); setTotals([]); return; }
    // Capture this week's snapshot (self-guarded to once a week), then load.
    const cap = window.BBR_supabase.rpc ? window.BBR_supabase.rpc("capture_leaderboard_snapshot").then(() => {}, () => {}) : Promise.resolve();
    cap.then(() => Promise.all([
      // Ranking spine: ALL collectors, identity gated server-side (private rows
      // come back with null display_name/member_since). The public view returns
      // ONLY public collectors' items — used for covers + the item-level boards,
      // so private collections are never exposed there.
      window.BBR_supabase.rpc("leaderboard_totals", {}),
      window.BBR_supabase.from("public_collection_items").select("*"),
      window.BBR_supabase.from("leaderboard_snapshots").select("user_id,total_value,captured_on").then(r => r, () => ({ data: [] })),
    ])).then(([totalsR, itemsR, snapR]) => {
      if (cancelled) return;
      // A raw Postgres string ("permission denied for relation…") tells a reader
      // nothing and reads like a broken site. Human message on screen, the
      // technical detail to the console where it is actually useful.
      if (totalsR.error) {
        console.error("[BBR] leaderboard_totals failed:", totalsR.error);
        setErr(LB_LOAD_ERR); setTotals([]); setItems([]); return;
      }
      setTotals(totalsR.data || []);
      setItems(itemsR.error ? [] : (itemsR.data || []));
      setSnaps((snapR && snapR.data) || []);
    }).catch((e) => {
      console.error("[BBR] leaderboard load failed:", e);
      if (!cancelled) { setErr(LB_LOAD_ERR); setTotals([]); setItems([]); }
    });
    return () => { cancelled = true; };
  }, []);

  const genres = useLbMemo(() => Array.from(new Set((items || []).map(i => i.genre).filter(Boolean))).sort(), [items]);
  useLbEffect(() => { if (genres.length && !genre) setGenre(genres[0]); }, [genres]);

  // The genre board ranks ALL collectors within a genre (private anonymised),
  // so it comes from the server function, not the public-only items.
  useLbEffect(() => {
    if (board !== "genre" || !genre || !window.BBR_supabase || !window.BBR_supabase.rpc) return;
    let cancelled = false;
    setGenreTotals(null);
    window.BBR_supabase.rpc("leaderboard_totals", { genre_filter: genre })
      .then(({ data, error }) => { if (!cancelled) setGenreTotals(error ? [] : (data || [])); }, () => { if (!cancelled) setGenreTotals([]); });
    return () => { cancelled = true; };
  }, [board, genre]);

  // Crown cover within the selected genre — public collectors only.
  const genreCrown = useLbMemo(() => {
    const m = {};
    if (board !== "genre" || !genre) return m;
    (items || []).forEach(it => {
      if (it.genre !== genre) return;
      const v = Number(it.est_value) || 0;
      if (!m[it.user_id] || v > (Number(m[it.user_id].est_value) || 0)) m[it.user_id] = it;
    });
    return m;
  }, [items, board, genre]);

  // Previous-week rank per user, from the most recent snapshot date before today.
  const prevRank = useLbMemo(() => {
    if (!snaps.length) return {};
    const today = new Date().toISOString().slice(0, 10);
    const dates = Array.from(new Set(snaps.map(s => s.captured_on))).sort().reverse();
    const priorDate = dates.find(d => d < today);
    if (!priorDate) return {};
    const rows = snaps.filter(s => s.captured_on === priorDate).slice().sort((a, b) => Number(b.total_value) - Number(a.total_value));
    const m = {}; rows.forEach((s, i) => { m[s.user_id] = i + 1; });
    return m;
  }, [snaps]);

  // Item-level enrichment (PUBLIC collectors only — private users aren't in the
  // public view): each public collector's crown-jewel record + genres present.
  const itemAgg = useLbMemo(() => {
    const m = {};
    (items || []).forEach(it => {
      const u = m[it.user_id] || (m[it.user_id] = { crown: null, genres: new Set() });
      const v = Number(it.est_value) || 0;
      if (it.genre) u.genres.add(it.genre);
      if (!u.crown || v > (Number(u.crown.est_value) || 0)) u.crown = it;
    });
    return m;
  }, [items]);

  // Build a ranked row from a leaderboard_totals() row, enriched with the
  // collector's crown cover when public. Private rows carry is_public=false and
  // null display_name/member_since (anonymised at render).
  function lbRow(t, crown) {
    return {
      user_id: t.user_id,
      display_name: t.display_name,
      is_public: !!t.is_public,
      member_since: t.member_since,
      total: Number(t.total_value) || 0,
      count: Number(t.item_count) || 0,
      canon_owned: Number(t.canon_owned) || 0,
      crown: crown || null,
      genre_count: (itemAgg[t.user_id] && itemAgg[t.user_id].genres.size) || 0,
    };
  }

  // The ranking spine — every collector (Option B), enriched for public ones.
  const users = useLbMemo(() => {
    if (!totals) return null;
    return totals.map(t => lbRow(t, itemAgg[t.user_id] && itemAgg[t.user_id].crown));
  }, [totals, itemAgg]);

  // Aggregate stats across every public collection (for the hero band).
  const stats = useLbMemo(() => {
    if (!totals) return null;
    // collectors / records / value count EVERYONE (board density preserved);
    // canon + genres are counted from public collections only (private items
    // aren't exposed) — a minor undercount with no private users at launch.
    const totalValue = totals.reduce((s, t) => s + (Number(t.total_value) || 0), 0);
    const records = totals.reduce((s, t) => s + (Number(t.item_count) || 0), 0);
    const canonOwned = new Set((items || []).filter(i => i.slug).map(i => i.slug)).size;
    return {
      collectors: totals.length,
      records,
      totalValue,
      canonOwned,
      genreCount: genres.length,
    };
  }, [totals, items, genres]);

  // Genre champions — top collector (by value in that genre) per genre, with
  // that champion's single best record in the genre (for the cover).
  const champions = useLbMemo(() => {
    if (!items) return [];
    const map = {}; // genre -> user_id -> { value, count, top }
    items.forEach(it => {
      if (!it.genre) return;
      const g = map[it.genre] || (map[it.genre] = {});
      const u = g[it.user_id] || (g[it.user_id] = { value: 0, count: 0, top: null, name: null });
      const v = Number(it.est_value) || 0; u.value += v; u.count += 1;
      if (it.display_name) u.name = it.display_name;
      if (!u.top || v > (Number(u.top.est_value) || 0)) u.top = it;
    });
    // items are public-only, so champions are inherently public collectors.
    return Object.keys(map).map(g => {
      const entries = Object.entries(map[g]).sort((a, b) => b[1].value - a[1].value);
      const [uid, d] = entries[0];
      return { genre: g, user_id: uid, value: d.value, count: d.count, top: d.top, contenders: entries.length, display_name: d.name };
    }).sort((a, b) => b.value - a.value);
  }, [items]);
  const genreLeaders = useLbMemo(() => new Set(champions.map(c => c.user_id)), [champions]);

  // Rarest / standout — most valuable individual records across all collections.
  const rarest = useLbMemo(
    () => (items || []).filter(r => Number(r.est_value) > 0).slice().sort((a, b) => Number(b.est_value) - Number(a.est_value)).slice(0, 12),
    [items]
  );

  const ranked = useLbMemo(() => {
    let list;
    if (board === "genre") {
      if (!genreTotals) return null;
      list = genreTotals.map(t => lbRow(t, genreCrown[t.user_id]));
    } else {
      if (!users) return null;
      list = users.slice();
    }
    if (board === "canon") list.sort((a, b) => (b.canon_owned - a.canon_owned) || (b.total - a.total));
    else list.sort((a, b) => b.total - a.total);
    return list;
  }, [users, genreTotals, genreCrown, board]);

  const maxVal = ranked && ranked.length ? (board === "canon" ? 100 : (ranked[0].total || 1)) : 1;

  // Fetch real cover art for the non-canon records on display (rarest finds +
  // each collector's crown jewel). Canon picks already have on-disk covers.
  useLbEffect(() => {
    if (!items) return;
    const seen = new Set();
    const recs = [];
    const add = (r) => {
      if (!r || r.slug || !(Number(r.est_value) > 0)) return;
      const k = lbCoverKey(r);
      if (!seen.has(k)) { seen.add(k); recs.push(r); }
    };
    rarest.forEach(add);
    (users || []).forEach(u => add(u.crown));
    const todo = recs.filter(r => !coverReq.current.has(lbCoverKey(r)));
    if (!todo.length) return;
    todo.forEach(r => coverReq.current.add(lbCoverKey(r)));
    let cancelled = false;
    Promise.all(todo.map(r => lbFetchCover(r).then(url => [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; };
  }, [items, rarest, users]);

  // Album descriptor for a record, with a fetched cover URL spliced in for
  // non-canon records (Sleeve renders album.coverUrl as an <img>).
  function lbCoverAlbum(r) {
    const a = lbAlbumFor(r, albums);
    if (a && !a.slug) {
      const u = covers[lbCoverKey(r)];
      if (u) return Object.assign({}, a, { coverUrl: u });
    }
    return a;
  }

  // Where the signed-in collector actually stands, and how far to the next place up.
  // Without this the board is a list of other people: the row carried a `me` class
  // but no rank readout and no way to reach it on an unpaginated, value-sorted list.
  function myStanding() {
    if (!currentUserId || !ranked || !ranked.length) return null;
    const i = ranked.findIndex((r) => r.user_id === currentUserId);
    if (i < 0) {
      // Signed in with no records yet: the honest state, plus the one action that
      // changes it. Anything else here would be a rank that doesn't exist.
      return (
        <div className="lb-standing lb-standing-empty">
          <div className="lb-standing-main">You&rsquo;re not ranked yet</div>
          <div className="lb-standing-sub">
            Log one record and you join the board — {ranked.length} collectors are on it.
          </div>
        </div>
      );
    }
    const meRow = ranked[i];
    const above = i > 0 ? ranked[i - 1] : null;
    const isCanon = board === "canon";
    const myFig = isCanon ? meRow.canon_owned : meRow.total;
    const gap = above ? ((isCanon ? above.canon_owned : above.total) - myFig) : 0;
    // Plain text with a real typographic apostrophe — no innerHTML needed for it.
    const gapTxt = !above
      ? "You\u2019re top of the board."
      : isCanon
        ? `${gap} more of the Hundred to pass \u2116${i}.`
        : `${lbGbp(gap)} to pass \u2116${i}.`;
    return (
      <div className="lb-standing">
        <div className="lb-standing-main">
          You&rsquo;re <strong>№{i + 1}</strong> of {ranked.length}
        </div>
        <div className="lb-standing-sub">{gapTxt}</div>
        <button className="lb-standing-jump" onClick={() => {
          const el = document.getElementById("lb-me");
          if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); el.classList.add("flash"); setTimeout(() => el.classList.remove("flash"), 1400); }
        }}>Show my row</button>
      </div>
    );
  }

  function podium() {
    if (!ranked || ranked.length < 3 || board === "champions" || board === "rarest") return null;
    const order = [ranked[1], ranked[0], ranked[2]];
    return (
      <div className="lb-podium">
        {order.map((r) => {
          const place = ranked.indexOf(r) + 1;
          const pub = r.is_public;
          const me = r.user_id === currentUserId;
          const a = pub ? lbCoverAlbum(r.crown) : null;
          const nm = me ? "You" : (pub ? (r.display_name || "Collector") : "Anonymous collector");
          // A private collector has no collection to open, so their step of the
          // podium is not a control. It used to stay a <button> with onClick
          // undefined: focusable, Enter did nothing. See LB_ROW_PRIVATE_NOTE.
          const Pod = pub ? "button" : "div";
          return (
            <Pod className={"lb-pod lb-pod-" + place + (pub ? "" : " anon")} key={r.user_id}
              title={pub ? undefined : "This collector keeps their collection private"}
              onClick={pub ? () => onViewCollection(r.user_id, r.display_name) : undefined}>
              {place === 1 && <span className="lb-pod-shine" aria-hidden="true" />}
              <span className="lb-pod-cover">
                {a ? <Sleeve album={a} size={place === 1 ? 76 : 58} />
                   : <img className="lb-pod-logo" src="/logo-mark.png" alt="" style={{ width: place === 1 ? 76 : 58, height: place === 1 ? 76 : 58 }} />}
                {pub && <span className="lb-pod-crownico"><CrownIcon size={11} /></span>}
              </span>
              <span className={"lb-pod-medal m" + place}>{place}</span>
              <span className="lb-pod-name">{nm}</span>
              {/* Value shows for everyone, including private collectors. The line
                  drawn here is STANDING vs CONTENTS: rank, total and record count are
                  the board, and a board with dashes in its top three rows is not one.
                  What stays private is what they own — the name, the crown jewel, the
                  genre spread, and the collection page itself. Private used to hide
                  the total as well, which meant the three biggest collections on the
                  site showed "—" and the homepage mini-board disagreed with the board
                  it summarises.

                  THIS IS A DELIBERATE DECISION, NOT AN OVERSIGHT — flagged as a
                  privacy reduction when it shipped (3 Aug 2026) and confirmed: a
                  total is not an identifier, and without it the leaderboard is
                  redundant. Do not "fix" this back to a hidden value. If it ever
                  needs revisiting, the thing to change is what counts as contents,
                  not whether a rank is allowed a number next to it. The user-facing
                  copy in AccountSettings and the /help FAQ describes exactly this
                  split; move both if you move this. */}
              <span className="lb-pod-val">
                {board === "canon"
                  ? <><b>{r.canon_owned}</b> / 100</>
                  : <><span className="cur">£</span><LbNum value={r.total} /></>}
              </span>
              <span className="lb-pod-sub">{r.count} records · {r.canon_owned} of 100</span>
              <span className="lb-pod-bar" />
            </Pod>);
        })}
      </div>);
  }

  const HdTk = window.HdTicker;
  return (
    <div className="lb">
      {HdTk ? <HdTk /> : null}
      <div className="lb-head">
        <h1 className="lb-title">Leaderboards</h1>
        {/* "Every collection is public" was true once and has been false since
            collections went private-by-default. What IS true — and worth
            keeping — is that nothing about the maths is hidden. */}
        <p className="lb-sub">Collections are private by default; a collector appears here under their own name only if they choose to show their shelf. Everyone else still ranks, anonymously. Conditions and value maths are shown openly — the board judges itself.</p>
      </div>

      {stats && (
        <div className="lb-stats">
          <div className="lb-stat">
            <div className="lb-stat-n"><LbNum value={stats.collectors} /></div>
            <div className="lb-stat-l">Collectors</div>
          </div>
          <div className="lb-stat">
            <div className="lb-stat-n"><LbNum value={stats.records} /></div>
            <div className="lb-stat-l">Records logged</div>
          </div>
          <div className="lb-stat lb-stat-hero">
            <div className="lb-stat-n"><span className="cur">£</span><LbNum value={stats.totalValue} /></div>
            <div className="lb-stat-l">Total catalogue value</div>
          </div>
          <div className="lb-stat">
            <div className="lb-stat-n"><LbNum value={stats.canonOwned} /><small> / 100</small></div>
            <div className="lb-stat-l">Of the Hundred owned</div>
          </div>
          <div className="lb-stat">
            <div className="lb-stat-n"><LbNum value={stats.genreCount} /></div>
            <div className="lb-stat-l">Genres represented</div>
          </div>
        </div>
      )}

      {/* The five boards are one tablist. Roving tabindex: a keyboard user tabs
          into the strip ONCE and arrows between boards, rather than tabbing
          through five look-alike controls with nothing announcing which is on.
          Each tab points at the single board panel below.
          CSS NOTE — not ours to change: .lb-tabs in collection.css is
          overflow-x:auto with scrollbar-width:none and ::-webkit-scrollbar
          {display:none}, so on a narrow screen the last board sits off the edge
          with no hint it is there. Arrow keys reach it now; the eye still
          needs an affordance (an edge fade, or letting the strip wrap). */}
      <div className="lb-tabs" role="tablist" aria-label="Leaderboard views" ref={tabsRef} onKeyDown={onTabKey}>
        {LB_BOARDS.map(([key, label]) => (
          <button key={key} id={"lb-tab-" + key} role="tab"
            className={"lb-tab " + (board === key ? "active" : "")}
            aria-selected={board === key} aria-controls="lb-board"
            tabIndex={board === key ? 0 : -1}
            onClick={() => setBoard(key)}>{label}</button>
        ))}
      </div>

      {board === "genre" && genres.length > 0 && (
        <div className="lb-genre-select">
          <select value={genre} onChange={e => setGenre(e.target.value)}>
            {genres.map(g => <option key={g} value={g}>{g}</option>)}
          </select>
        </div>
      )}
      {board === "canon" && <p className="lb-note">Hardest board to game: ranked by how many of the Lead-In 100 you actually own.</p>}
      {board === "value" && <p className="lb-note">Movement (&#9650;/&#9660;) shows each collector&rsquo;s rank change since last week&rsquo;s standings.</p>}

      <div id="lb-board" role="tabpanel" aria-labelledby={"lb-tab-" + board}>
      {/* ---- Genre champions board ---- */}
      {board === "champions" ? (
        items === null ? <div className="lb-empty">Loading&hellip;</div>
        : champions.length === 0 ? <div className="lb-empty">No genres on the board yet.</div>
        : <div className="lb-champ-grid">
            {champions.map((c, i) => {
              const a = lbAlbumFor(c.top, albums);
              return (
                <button className="lb-champ" key={c.genre} style={{ "--i": i }} onClick={() => onViewCollection(c.user_id, c.display_name)}>
                  <div className="lb-champ-cover">{a ? <Sleeve album={a} size={64} /> : null}</div>
                  <div className="lb-champ-body">
                    <div className="lb-champ-genre">{c.genre}</div>
                    <div className="lb-champ-name">{c.display_name || "Collector"}</div>
                    <div className="lb-champ-val"><span className="cur">£</span><LbNum value={c.value} /></div>
                    <div className="lb-champ-tag"><CrownIcon size={11} /> Genre champion · {c.count} record{c.count == 1 ? "" : "s"}{c.contenders > 1 ? " · beat " + (c.contenders - 1) : ""}</div>
                  </div>
                </button>);
            })}
          </div>

      /* ---- Rarest finds board ---- */
      ) : board === "rarest" ? (
        items === null ? <div className="lb-empty">Loading&hellip;</div>
        : rarest.length === 0 ? <div className="lb-empty">No valued records on the board yet.</div>
        : <div className="lb-rare-grid">
            {rarest.map((r, i) => (
              <button className={"lb-rare-card" + (i === 0 ? " top" : "")} key={r.id} style={{ "--i": i }} onClick={() => onViewCollection(r.user_id, r.display_name)}>
                <span className="lb-rare-cover"><Sleeve album={lbCoverAlbum(r)} size={150} /></span>
                <span className="lb-rare-rank">{i + 1}</span>
                <span className="lb-rare-v"><span className="cur">£</span><LbNum value={r.est_value} /></span>
                <span className="lb-rare-body">
                  <span className="lb-rare-t">{r.title}</span>
                  <span className="lb-rare-a">{r.artist}{r.year ? " · " + r.year : ""}</span>
                  <span className="lb-rare-owner">{lbGrade(r.media_condition)} · {r.display_name || "a collector"}</span>
                </span>
              </button>))}
          </div>

      /* ---- Value / genre / canon ranked board ---- */
      ) : ranked === null ? (
        <div className="lb-empty">Loading the board&hellip;</div>
      ) : err ? (
        <div className="lb-empty">{err}</div>
      ) : ranked.length === 0 ? (
        /* This was a sentence that looked like an offer and wasn't one — "Be the
           first — add a record" as flat text, with nothing to press. */
        <div className="lb-empty">
          <p style={{ margin: "0 0 16px" }}>No collections on the board yet.</p>
          <button className="mc-btn solid" onClick={() => lbJoin("collection")}>Be the first &mdash; add a record</button>
        </div>
      ) : (
        <>
          {myStanding()}
          {podium()}
          <div className="lb-list">
            {ranked.map((r, i) => {
              const pub = r.is_public;
              const me = r.user_id === currentUserId;
              const crownAlbum = pub ? lbCoverAlbum(r.crown) : null;
              const barPct = board === "canon" ? (r.canon_owned / 100 * 100) : (r.total / maxVal * 100);
              const badges = [];
              if (window.collectorTierName) badges.push(window.collectorTierName(r.count));  // collector level, leads
              if (i < 10) badges.push("Top 10");
              if (pub && genreLeaders.has(r.user_id)) badges.push("Genre leader");
              if (r.canon_owned >= 50) badges.push("Half the canon");
              else if (r.count >= 50) badges.push("50+ records");
              const mv = (pub && board === "value") ? lbMoverChip(prevRank[r.user_id] != null ? (prevRank[r.user_id] - (i + 1)) : null) : null;
              const since = pub ? lbSince(r.member_since) : null;
              const nm = me ? "You" : (pub ? (r.display_name || "Collector") : "Anonymous collector");
              // LB_ROW_PRIVATE_NOTE — a private collector's row opens nothing.
              // Leaving it a <button> with onClick undefined made every private
              // row a focus stop that swallowed Enter: a keyboard user tabbed
              // through the whole board pressing Return at dead controls. A row
              // that does nothing should not claim to be a control.
              const Row = pub ? "button" : "div";
              return (
                <Row id={me ? "lb-me" : undefined} className={"lb-row" + (me ? " me" : "") + (i < 3 ? " top" : "") + (pub ? "" : " anon")} key={r.user_id} style={{ "--i": i }}
                  title={pub ? undefined : "This collector keeps their collection private"}
                  onClick={pub ? () => onViewCollection(r.user_id, r.display_name) : undefined}>
                  <span className={"lb-rank" + (i < 3 ? " medal r" + i : "")}>{i + 1}</span>
                  <span className="lb-crown" title={pub && r.crown ? "Crown jewel: " + r.crown.title : ""}>
                    {crownAlbum ? <Sleeve album={crownAlbum} size={48} /> : <img className="lb-crown-logo" src="/logo-mark.png" alt="" />}
                    {pub && <span className="lb-crown-badge"><CrownIcon size={10} /></span>}
                  </span>
                  <span className="lb-name-col">
                    <span className="lb-name">{nm} {mv}</span>
                    <span className="lb-sub-line">
                      {r.canon_owned} of the Hundred
                      {pub && r.genre_count ? " · " + r.genre_count + " genre" + (r.genre_count == 1 ? "" : "s") : ""}
                      {since ? " · since " + since : ""}
                    </span>
                    <span className="lb-badges">
                      {badges.slice(0, 3).map(b => <span className="lb-badge" key={b}>{b}</span>)}
                    </span>
                    <span className="lb-bar"><i style={{ "--w": Math.max(2, barPct) + "%" }} /></span>
                  </span>
                  <span className="lb-meta">
                    {/* Same rule as the podium above: standing is public, contents
                        are not. See the note there. */}
                    {board === "canon"
                      ? <><b>{r.canon_owned}</b> / 100</>
                      : <><b><span className="cur">£</span><LbNum value={r.total} /></b><small>{r.count} record{r.count == 1 ? "" : "s"}</small></>}
                  </span>
                </Row>
              );
            })}
          </div>
        </>
      )}
      </div>

      {/* The board is the highest-intent page on the site for a visitor who has
          not signed up: real collections, real money, live ranks. It offered no
          way in. Every number below is read off the board already on screen, so
          nothing here can rot or be invented. */}
      {signedOut && stats && stats.collectors > 0 && (
        <LbJoinCard
          eyebrow="Join the board"
          heading="Your shelf has a number on it too."
          body={
            stats.records > 0
              ? "There are " + stats.collectors + " collectors here holding " + stats.records.toLocaleString("en-GB") +
                " records between them, priced at " + lbGbp(stats.totalValue) + ". Log yours and you get the same maths: " +
                "every pressing valued at its market median, graded for condition, ranked against everyone else. Stay private and you still rank — anonymously."
              : "Log your records and you get the same maths as everyone on this board: every pressing valued at its market median, graded for condition, ranked. Stay private and you still rank — anonymously."
          }
          cta="Value my collection"
          intent="collection"
        />
      )}

      {/* Cross-links: the leaderboard was a dead-end — route onward/back into
          the loop (your collection, deep dives, the ranked Hundred). */}
      {window.ExploreMore && <ExploreMore from="leaderboards" exclude={["leaderboards"]} />}
    </div>
  );
}

// ---- Public collection view ------------------------------------------------
function PublicCollectionV2({ userId, displayName, albums, onBack }) {
  const [items, setItems] = useLbState(null);
  const [since, setSince] = useLbState(null);
  const [ownerPublic, setOwnerPublic] = useLbState(undefined); // undefined=loading, true/false, null=unknown
  const [search, setSearch] = useLbState("");
  const [filterGenre, setFilterGenre] = useLbState("all");
  const [sortKey, setSortKey] = useLbState("value");
  const [covers, setCovers] = useLbState({}); // cover-art key -> url|null (non-canon records)
  const coverReq = React.useRef(new Set());
  // This page has no `user` prop (App.jsx owns the call signature), so read the
  // session directly — see useLbSignedOut.
  const signedOut = useLbSignedOut(null);

  useLbEffect(() => {
    let cancelled = false;
    setItems(null); setSince(null); setOwnerPublic(undefined);
    // No backend wired up (config/CDN failure): show the empty state instead of
    // throwing on the missing client, which is what Leaderboards_v2 already does
    // and what this effect did NOT — a failed supabase-js load crashed the page
    // rather than degrading it.
    if (!window.BBR_supabase) { setItems([]); setOwnerPublic(null); return; }
    // The public view returns rows only for PUBLIC collectors (server-enforced),
    // so any rows here means this collector is public; member_since rides along.
    window.BBR_supabase.from("public_collection_items").select("*").eq("user_id", userId)
      .order("est_value", { ascending: false, nullsFirst: false })
      .then(({ data }) => {
        if (cancelled) return;
        const rows = data || [];
        setItems(rows);
        if (rows[0] && rows[0].member_since) setSince(lbSince(rows[0].member_since));
      });
    // Tell "private" apart from "empty" without leaking: the gated function
    // lists a private collector (is_public=false) only if they have any items.
    if (window.BBR_supabase.rpc) {
      window.BBR_supabase.rpc("leaderboard_totals", {})
        .then(({ data }) => {
          if (cancelled) return;
          const row = (data || []).find(t => t.user_id === userId);
          setOwnerPublic(row ? !!row.is_public : null);
        }, () => { if (!cancelled) setOwnerPublic(null); });
    } else { setOwnerPublic(null); }
    return () => { cancelled = true; };
  }, [userId]);

  // Fetch real cover art for this collection's non-canon (manual) records.
  useLbEffect(() => {
    if (!items) return;
    const seen = new Set();
    const todo = [];
    items.forEach(r => {
      if (r.slug) return; // canon picks use their on-disk cover
      const k = 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 => lbFetchCover(r).then(url => [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; };
  }, [items]);

  function pcCoverAlbum(r) {
    const a = lbAlbumFor(r, albums);
    if (a && !a.slug) {
      const u = covers[lbCoverKey(r)];
      if (u) return Object.assign({}, a, { coverUrl: u });
    }
    return a;
  }

  const total = (items || []).reduce((s, r) => s + (Number(r.est_value) || 0), 0);
  const canon = new Set((items || []).filter(r => r.slug).map(r => r.slug)).size;
  const name = (items && items[0] && items[0].display_name) || displayName || "Collector";

  const genres = useLbMemo(() => Array.from(new Set((items || []).map(r => r.genre).filter(Boolean))).sort(), [items]);
  const view = useLbMemo(() => {
    const list = (items || []).filter(r => {
      if (filterGenre !== "all" && r.genre !== filterGenre) return false;
      if (search && !((r.title + " " + r.artist).toLowerCase().includes(search.toLowerCase()))) return false;
      return true;
    });
    const cmp = {
      value: (a, b) => (Number(b.est_value) || 0) - (Number(a.est_value) || 0),
      artist: (a, b) => (a.artist || "").localeCompare(b.artist || ""),
      year: (a, b) => (a.year || 0) - (b.year || 0),
    }[sortKey] || ((a, b) => (Number(b.est_value) || 0) - (Number(a.est_value) || 0));
    return list.slice().sort(cmp);
  }, [items, filterGenre, search, sortKey]);

  return (
    <div className="pubcol">
      <button className="lb-back" onClick={onBack}>← Leaderboards</button>
      <div className="pubcol-head">
        <h1 className="lb-title">{name}'s collection</h1>
        {items && items.length > 0 && (
          <>
            <div className="pubcol-stats">
              {window.CollectorBadge ? React.createElement(window.CollectorBadge, { count: (items || []).length }) : null}
              <span className="mc-chip">{lbGbp(total)} total</span>
              <span className="mc-chip">{(items || []).length} records</span>
              <span className="mc-chip">{canon} / 100 of the Hundred</span>
              {since && <span className="mc-chip">collecting since {since}</span>}
            </div>
            <p className="lb-sub">Public collection — conditions and estimated values shown for the community to judge.</p>
          </>
        )}
      </div>

      {items === null || (items.length === 0 && ownerPublic === undefined) ? (
        <div className="lb-empty">Loading…</div>
      ) : items.length === 0 ? (
        ownerPublic === false
          ? <div className="lb-empty">This collection is private.</div>
          : <div className="lb-empty">This collector hasn't added any records yet.</div>
      ) : (
       <>
        <div className="mc-bar">
          <div className="mc-controls" style={{ marginLeft: "auto" }}>
            <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 this collection…" value={search} onChange={e => setSearch(e.target.value)} />
            </div>
            <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>
            <div className="mc-select">
              <select value={sortKey} onChange={e => setSortKey(e.target.value)}>
                <option value="value">Sort: Value</option>
                <option value="artist">Sort: Artist</option>
                <option value="year">Sort: Year</option>
              </select>
            </div>
          </div>
        </div>
        <div className="mc-grid">
          {view.map(r => (
            <div className="mc-card" key={r.id}>
              <div className="mc-card-cover"><Sleeve album={pcCoverAlbum(r)} 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" title="Media / Sleeve">{lbGrade(r.media_condition)}<small style={{ opacity: .6 }}>/{lbGrade(r.sleeve_condition)}</small></span>
                </div>
                <div className="mc-card-meta">
                  {r.genre || "—"}
                  {(() => {
                    const p = r.verification_level === "scanned"
                      ? { cls: "scanned", label: "✓ scanned" }
                      : r.verification_level === "catalogue"
                        ? { cls: "catalogue", label: "cat no" }
                        : { cls: "manual", label: "manual" };
                    return <span className={"v2-badge " + p.cls}>{p.label}</span>;
                  })()}
                </div>
                <div className="mc-card-foot">
                  <div>
                    <div className="mc-card-val"><span className="cur">£</span><AnimatedMoney value={Number(r.est_value) || 0} /></div>
                    <div className="mc-card-range">{Number(r.est_value) > 0 ? "est. value" : "unvalued"}</div>
                  </div>
                </div>
              </div>
            </div>
          ))}
        </div>
       </>
      )}

      {/* A shared link lands a stranger here, on somebody else's shelf, with one
          control on the page: "← Leaderboards". This is the other exit. The
          numbers are this collection's own, so there is nothing to invent and
          nothing to keep up to date. */}
      {signedOut && items && (
        items.length > 0 ? (
          <LbJoinCard
            eyebrow="Your own shelf"
            heading="This is what your records would look like."
            body={"Every one of " + name + "'s " + items.length + " records is priced at its market median and graded for condition — " +
                  lbGbp(total) + " of vinyl, added up. Yours can read the same way, and it costs nothing to find out what you are sitting on."}
            cta="Value my collection"
            intent="collection"
          />
        ) : (
          /* Private or empty is still a dead end for a visitor who followed a
             link here — one "← Leaderboards" button and nothing else. Copy stays
             neutral: this branch covers both "private" and "no records yet". */
          <LbJoinCard
            eyebrow="Your own shelf"
            heading="Find out what your own shelf is worth."
            body="Log your records and each pressing is priced at its market median and graded for condition, so you know what the collection is actually worth. Collections are private by default — going public is one switch, and staying private still earns you a rank."
            cta="Value my collection"
            intent="collection"
          />
        )
      )}
    </div>
  );
}

Object.assign(window, { Leaderboards_v2, PublicCollectionV2, lbFetchCover, lbCoverKey });
