// ===========================================================
// ShareCards (C2, roadmap F9, 29 Sep 2026): the story card set.
//
// 1080x1920 images for Instagram and TikTok stories, "the one place a record
// cover beats a link" (the roadmap). Drawn on a canvas in the owner's browser,
// as the shelf preview is (ShelfShare.jsx): the site has no build step, so no
// server-side image rendering. The mockups are Card-Shelf, Card-Grail and
// Card-BestDig and Card-Month on the Culture canvas. Each has a 1200x630 twin
// its own link unfurls as (part 3, card_twins.sql).
//
//   your ten    the front ten (F6), in their tier treatments, and the counts
//   your Grail  the shelf's best-tiered pressing; "your rarest" without a Grail
//   best dig    the biggest gain over what was paid (BestDig.jsx computes it)
//   the month   what the month did: shelf_month() (month_card.sql), part 2
//
// EVERY CARD CARRIES THE SHELF LINK, printed at its foot, and the link that
// travels with it carries the owner's referral code tagged with the card
// (?rk=card_grail), so landings and signups count per card type (data/
// supabase.js REF_KINDS, claim_referral()). Anonymity (G3): the card shows the
// name or the alias exactly as the shelf does, and the alias link for an
// anonymous shelf, because the caller hands over ShelfShare's own identity.
//
// Covers come through ShelfShare's shCoverSrc: canon records from the
// same-origin /covers, everything else through /api/cover?img=, the passthrough
// that keeps the canvas exportable.
//
//   BBR_cards.cards({ records, front, chosen, tierOf, nmOf, counts, siteCounts, focusId, month })
//     -> the cards this shelf can make, [{ kind, title, sub, cover, data }]
//   BBR_cards.render(card, who) -> Promise<canvas>     who: { label, first, path }
//   BBR_cards.send(card, canvas, who) -> "file" | "download" | "cancelled"
//   BBR_cards.storeTwin(card, who) -> Promise<boolean>: the 1200x630 twin
//     recorded, so the card's link (?c=<kind>) unfurls as that card
//
// Globals in: React, BBR_ref, shCoverSrc, shLoadImg, bdDrawMark, bdWrap,
// computeBestDig, tierRank. Exposes window.BBR_cards.
// ===========================================================
const CRD_W = 1080, CRD_H = 1920;
const CRD_SITE = "https://www.theleadin.com";
// The mockups' dark palette. Literal, as the shelf preview's are: an image is
// the same colour wherever it is posted.
const CRD = {
  INK: "#17130f", INK2: "#241e18", PAPER: "#f3ede1", PAPER2: "#cfc6b6", MUTE: "#a89e8e", MUTE2: "#8a8072",
  RULE: "#3a322a", KICK: "#ea8c77", ACCENT: "#d94e2c", GAIN: "#7fcf8f",
};
const CRD_TIER_WORD = { grail: "Grail", rare: "Rare", scarce: "Scarce" };

function crdGbp(n) { return "£" + Math.round(Number(n) || 0).toLocaleString("en-GB"); }

async function crdFonts() {
  try {
    await Promise.all([
      document.fonts.load("400 100px 'DM Serif Display'"),
      document.fonts.load("italic 400 80px 'DM Serif Display'"),
      document.fonts.load("800 88px 'Inter'"),
      document.fonts.load("600 28px 'Inter'"),
      document.fonts.load("500 24px 'JetBrains Mono'"),
      document.fonts.load("600 24px 'JetBrains Mono'"),
    ]);
    await document.fonts.ready;
  } catch (e) { /* draw with what is loaded */ }
  // The brand mark's artwork (BestDig.jsx bdDrawMark), so the logo is the real one.
  try { await window.bdMarkReady; } catch (e) { /* the vector fallback draws */ }
}

function crdCover(r) {
  if (!r || typeof window.shCoverSrc !== "function" || typeof window.shLoadImg !== "function") return Promise.resolve(null);
  return window.shCoverSrc(r).then((s) => window.shLoadImg(s, 8000), () => null);
}

// A mono line with letter-spacing (canvas has none of its own before the
// letterSpacing property, so it is laid out a character at a time).
function crdSpaced(ctx, text, x, y, spacing, align) {
  const s = String(text || "");
  let w = 0;
  for (const ch of s) w += ctx.measureText(ch).width + spacing;
  w -= spacing;
  let cx = align === "center" ? x - w / 2 : align === "right" ? x - w : x;
  const prev = ctx.textAlign; ctx.textAlign = "left";
  for (const ch of s) { ctx.fillText(ch, cx, y); cx += ctx.measureText(ch).width + spacing; }
  ctx.textAlign = prev;
  return w;
}

function crdRoundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
}

// The Grail foil, the shelf's gradient (collection.css .tier-grail), drawn as
// a frame. Gold leaf is gold on any background.
function crdFoil(ctx, x, y, w, h) {
  const g = ctx.createLinearGradient(x, y, x + w, y + h);
  [["#8a6a12", 0], ["#f3e3a4", .18], ["#b9bcc2", .32], ["#f6d76e", .48], ["#9c7a1c", .62], ["#fff3c4", .78], ["#8a6a12", 1]]
    .forEach(([c, p]) => g.addColorStop(p, c));
  return g;
}

// One sleeve: the cover centre-cropped (a typographic tile if there is none),
// then the pressing's tier as the shelf wears it. On the dark card, Scarce's
// ink outline becomes a paper one.
function crdSleeve(ctx, img, r, x, y, size, tier) {
  ctx.save();
  ctx.textAlign = "left";
  if (tier === "grail") {
    ctx.save();
    ctx.shadowColor = "rgba(230,196,119,.45)"; ctx.shadowBlur = size * 0.12;
    ctx.fillStyle = crdFoil(ctx, x - size * 0.045, y - size * 0.045, size * 1.09, size * 1.09);
    ctx.fillRect(x - size * 0.045, y - size * 0.045, size * 1.09, size * 1.09);
    ctx.restore();
  }
  if (img) {
    const iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height, s = Math.min(iw, ih);
    ctx.drawImage(img, (iw - s) / 2, (ih - s) / 2, s, s, x, y, size, size);
  } else {
    ctx.fillStyle = CRD.INK2; ctx.fillRect(x, y, size, size);
    if (r) {
      ctx.fillStyle = CRD.PAPER; ctx.font = "400 " + Math.round(size * 0.12) + "px 'DM Serif Display', Georgia, serif";
      const lines = window.bdWrap ? window.bdWrap(ctx, r.title || "", size * 0.82, 3) : [r.title || ""];
      lines.forEach((ln, k) => ctx.fillText(ln, x + size * 0.09, y + size * 0.2 + k * size * 0.14));
      ctx.fillStyle = CRD.MUTE; ctx.font = "600 " + Math.round(size * 0.055) + "px 'JetBrains Mono', monospace";
      const a = String(r.artist || "").toUpperCase();
      ctx.fillText(a.length > 22 ? a.slice(0, 21) + "…" : a, x + size * 0.09, y + size * 0.9);
    }
  }
  if (tier === "rare") {
    const o = Math.max(6, size * 0.03);
    ctx.strokeStyle = CRD.ACCENT; ctx.lineWidth = Math.max(5, size * 0.022);
    ctx.strokeRect(x - o, y - o, size + 2 * o, size + 2 * o);
  } else if (tier === "scarce") {
    const o = Math.max(5, size * 0.024);
    ctx.strokeStyle = CRD.PAPER2; ctx.lineWidth = Math.max(2.5, size * 0.01);
    ctx.strokeRect(x - o, y - o, size + 2 * o, size + 2 * o);
  }
  ctx.restore();
}

// A tier chip, or a plain one.
function crdChip(ctx, x, y, text, tier, opts) {
  const fs = (opts && opts.size) || 24;
  ctx.font = "600 " + fs + "px 'JetBrains Mono', monospace";
  const label = String(text).toUpperCase();
  let w = 0; for (const ch of label) w += ctx.measureText(ch).width + fs * 0.1;
  const padX = fs * 0.62, h = fs * 1.9, bw = w + padX * 2;
  const bx = opts && opts.align === "center" ? x - bw / 2 : x;
  if (tier === "grail") { ctx.fillStyle = crdFoil(ctx, bx, y, bw, h); ctx.fillRect(bx, y, bw, h); ctx.fillStyle = "#2a1d00"; }
  else if (tier === "rare") { ctx.fillStyle = CRD.ACCENT; ctx.fillRect(bx, y, bw, h); ctx.fillStyle = "#fff"; }
  else if (tier === "scarce") { ctx.strokeStyle = CRD.PAPER; ctx.lineWidth = 2; ctx.strokeRect(bx + 1, y + 1, bw - 2, h - 2); ctx.fillStyle = CRD.PAPER; }
  else { ctx.strokeStyle = "#5b5248"; ctx.lineWidth = 2; ctx.strokeRect(bx + 1, y + 1, bw - 2, h - 2); ctx.fillStyle = CRD.PAPER2; }
  ctx.textBaseline = "middle";
  crdSpaced(ctx, label, bx + padX, y + h / 2 + 1, fs * 0.1);
  ctx.textBaseline = "alphabetic";
  return bw;
}

function crdHeader(ctx) {
  if (window.bdDrawMark) {
    ctx.save(); ctx.translate(72, 74); ctx.scale(0.72, 0.72); window.bdDrawMark(ctx, 0, 0, CRD.ACCENT, CRD.PAPER); ctx.restore();
  }
  ctx.textBaseline = "alphabetic";
  ctx.font = "italic 400 40px 'DM Serif Display', Georgia, serif"; ctx.fillStyle = CRD.MUTE;
  ctx.fillText("The", 146, 110);
  const tw = ctx.measureText("The ").width;
  ctx.font = "400 40px 'DM Serif Display', Georgia, serif"; ctx.fillStyle = CRD.PAPER;
  ctx.fillText("Lead-In", 146 + tw, 110);
}

// The shelf's address and the invitation, on every card.
function crdFooter(ctx, path) {
  const y = CRD_H - 150;
  ctx.strokeStyle = CRD.RULE; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(72, y); ctx.lineTo(CRD_W - 72, y); ctx.stroke();
  ctx.font = "500 26px 'JetBrains Mono', monospace"; ctx.fillStyle = CRD.PAPER2;
  let l = "theleadin.com" + (path || "");
  while (l.length > 16 && ctx.measureText(l).width > CRD_W - 144 - 360) l = l.slice(0, -2);
  ctx.fillText(l, 72, y + 64);
  ctx.fillStyle = CRD.KICK; ctx.font = "500 24px 'JetBrains Mono', monospace";
  ctx.textAlign = "right"; ctx.fillText("Start your own shelf →", CRD_W - 72, y + 64); ctx.textAlign = "left";
}

function crdBase(canvas) {
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = CRD.INK; ctx.fillRect(0, 0, CRD_W, CRD_H);
  return ctx;
}

// "1963 · Parlophone PCS 3042 · VG+", from what the collector's own row holds.
function crdDetails(r) {
  const grade = r && r.media_condition ? String(r.media_condition).replace("_PLUS", "+") : null;
  const label = [r && r.label, r && r.catalogue_no].filter(Boolean).join(" ");
  return [r && r.year, label || null, grade].filter(Boolean).join(" · ");
}

// ---- the three cards ---------------------------------------------------------

async function crdRenderTen(card, who) {
  const d = card.data;
  const canvas = document.createElement("canvas"); canvas.width = CRD_W; canvas.height = CRD_H;
  const ctx = crdBase(canvas);
  crdHeader(ctx);
  const nine = d.front.slice(0, 9);
  const imgs = await Promise.all(nine.map(crdCover));
  const CELL = 280, GAP = 36, gw = 3 * CELL + 2 * GAP, gx = (CRD_W - gw) / 2;
  ctx.font = "400 104px 'DM Serif Display', Georgia, serif";
  const title = window.bdWrap ? window.bdWrap(ctx, who.first + "’s ten", CRD_W - 144, 2) : [who.first + "'s ten"];
  const rows = Math.ceil(nine.length / 3);
  // Kicker, title, grid and chips, centred between the header (ends ~150) and
  // the footer rule (CRD_H - 150), as the mockup's space-between does.
  const blockH = 118 + (title.length - 1) * 110 + 72 + rows * CELL + (rows - 1) * GAP + 70 + 46;
  let y = Math.max(300, 150 + (CRD_H - 300 - blockH) / 2 + 26);
  ctx.fillStyle = CRD.KICK; ctx.font = "500 26px 'JetBrains Mono', monospace";
  crdSpaced(ctx, d.chosen ? "MY SHELF · MY TEN" : "MY SHELF · TOP TEN", 72, y, 3);
  y += 118;
  ctx.fillStyle = CRD.PAPER; ctx.font = "400 104px 'DM Serif Display', Georgia, serif";
  title.forEach((ln, k) => ctx.fillText(ln, 72, y + k * 110));
  y += (title.length - 1) * 110 + 72;
  for (let i = 0; i < nine.length; i++) {
    const x = gx + (i % 3) * (CELL + GAP), yy = y + Math.floor(i / 3) * (CELL + GAP);
    crdSleeve(ctx, imgs[i], nine[i], x, yy, CELL, d.tierOf(nine[i]));
  }
  y += rows * CELL + (rows - 1) * GAP + 70;
  let cx = 72;
  const c = d.counts || {};
  if (c.grail) cx += crdChip(ctx, cx, y, c.grail + " Grail", "grail") + 16;
  if (c.rare) cx += crdChip(ctx, cx, y, c.rare + " Rare", "rare") + 16;
  if (c.scarce && !(c.grail && c.rare)) cx += crdChip(ctx, cx, y, c.scarce + " Scarce", "scarce") + 16;
  crdChip(ctx, cx, y, d.n.toLocaleString("en-GB") + " record" + (d.n === 1 ? "" : "s"), null);
  crdFooter(ctx, who.path);
  return canvas;
}

async function crdRenderRarest(card, who) {
  const d = card.data, r = d.rec;
  const canvas = document.createElement("canvas"); canvas.width = CRD_W; canvas.height = CRD_H;
  const ctx = crdBase(canvas);
  crdHeader(ctx);
  const img = await crdCover(r);
  const C = CRD_W / 2;
  let y = 250;
  const chip = CRD_TIER_WORD[d.tier] + (d.topShare ? " · top " + d.topShare + " of pressings" : "");
  crdChip(ctx, C, y, chip, d.tier, { size: 26, align: "center" });
  y += 120;
  const S = 700;
  crdSleeve(ctx, img, r, (CRD_W - S) / 2, y, S, d.tier);
  y += S + 116;
  ctx.textAlign = "center";
  ctx.fillStyle = CRD.PAPER; ctx.font = "italic 400 80px 'DM Serif Display', Georgia, serif";
  const tl = window.bdWrap ? window.bdWrap(ctx, r.title || "", CRD_W - 160, 2) : [r.title || ""];
  tl.forEach((ln, k) => ctx.fillText(ln, C, y + k * 86));
  y += (tl.length - 1) * 86 + 58;
  ctx.fillStyle = CRD.PAPER2; ctx.font = "600 30px 'Inter', Helvetica, sans-serif";
  ctx.fillText(String(r.artist || ""), C, y);
  const det = crdDetails(r);
  if (det) { y += 50; ctx.font = "500 24px 'JetBrains Mono', monospace"; ctx.fillStyle = CRD.MUTE; ctx.fillText(det, C, y); }
  if (r.value > 0) {
    y += 124; ctx.fillStyle = CRD.PAPER; ctx.font = "800 96px 'Inter', Helvetica, sans-serif";
    ctx.fillText(crdGbp(r.value), C, y);
    if (d.nm > 0 && d.tier !== "scarce") {
      y += 50; ctx.fillStyle = CRD.MUTE2; ctx.font = "500 22px 'JetBrains Mono', monospace";
      ctx.fillText(("Near Mint " + crdGbp(d.nm)).toUpperCase(), C, y);
    }
  }
  ctx.textAlign = "left";
  crdFooter(ctx, who.path);
  return canvas;
}

async function crdRenderDig(card, who) {
  const d = card.data, r = d.rec;
  const canvas = document.createElement("canvas"); canvas.width = CRD_W; canvas.height = CRD_H;
  const ctx = crdBase(canvas);
  crdHeader(ctx);
  const img = await crdCover(r);
  const C = CRD_W / 2;
  let y = 290;
  ctx.textAlign = "center";
  ctx.fillStyle = CRD.KICK; ctx.font = "500 26px 'JetBrains Mono', monospace";
  crdSpaced(ctx, "MY BEST DIG", C, y, 3, "center");
  y += 60;
  const S = 660;
  crdSleeve(ctx, img, r, (CRD_W - S) / 2, y, S, d.tier);
  y += S + 116;
  ctx.fillStyle = CRD.PAPER; ctx.font = "italic 400 76px 'DM Serif Display', Georgia, serif";
  const tl = window.bdWrap ? window.bdWrap(ctx, r.title || "", CRD_W - 160, 2) : [r.title || ""];
  tl.forEach((ln, k) => ctx.fillText(ln, C, y + k * 82));
  y += (tl.length - 1) * 82 + 56;
  ctx.fillStyle = CRD.PAPER2; ctx.font = "600 30px 'Inter', Helvetica, sans-serif";
  ctx.fillText(String(r.artist || ""), C, y);
  const det = crdDetails(r);
  if (det) { y += 48; ctx.font = "500 24px 'JetBrains Mono', monospace"; ctx.fillStyle = CRD.MUTE; ctx.fillText(det, C, y); }
  // Paid -> worth now, then the multiple.
  y += 150;
  ctx.textAlign = "left";
  ctx.font = "800 64px 'Inter', Helvetica, sans-serif"; const paidW = ctx.measureText(crdGbp(d.price)).width;
  ctx.font = "400 64px 'DM Serif Display', Georgia, serif"; const arrW = ctx.measureText("→").width;
  ctx.font = "800 96px 'Inter', Helvetica, sans-serif"; const nowW = ctx.measureText(crdGbp(d.value)).width;
  const rowW = paidW + 56 + arrW + 56 + nowW;
  let x = C - rowW / 2;
  ctx.fillStyle = CRD.MUTE; ctx.font = "800 64px 'Inter', Helvetica, sans-serif"; ctx.fillText(crdGbp(d.price), x, y);
  ctx.fillStyle = CRD.MUTE2; ctx.font = "500 22px 'JetBrains Mono', monospace"; ctx.fillText("PAID", x, y + 42);
  x += paidW + 56;
  ctx.fillStyle = CRD.MUTE; ctx.font = "400 64px 'DM Serif Display', Georgia, serif"; ctx.fillText("→", x, y - 4);
  x += arrW + 56;
  ctx.fillStyle = CRD.PAPER; ctx.font = "800 96px 'Inter', Helvetica, sans-serif"; ctx.fillText(crdGbp(d.value), x, y);
  ctx.fillStyle = CRD.MUTE2; ctx.font = "500 22px 'JetBrains Mono', monospace"; ctx.fillText("WORTH NOW", x, y + 42);
  y += 150;
  ctx.textAlign = "center";
  ctx.fillStyle = CRD.GAIN; ctx.font = "400 68px 'DM Serif Display', Georgia, serif";
  const mult = d.value / d.price;
  ctx.fillText(mult >= 2 ? (mult >= 10 ? Math.round(mult) : Math.round(mult * 10) / 10) + "× what I paid" : "+" + d.pct + "% on what I paid", C, y);
  ctx.textAlign = "left";
  crdFooter(ctx, who.path);
  return canvas;
}

// ---- the month (F9 part 2; supabase/migrations/month_card.sql shelf_month) ---
const CRD_WORDS = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"];
function crdWord(n) { return n >= 0 && n < CRD_WORDS.length ? CRD_WORDS[n] : Number(n).toLocaleString("en-GB"); }
function crdOrdinal(n) { const s = ["th", "st", "nd", "rd"], v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); }
// A standing worth printing: the shelf's own chip rule (ShelfStandings.jsx),
// top ten AND top half, so a card never boasts "9th of 9".
function crdStandingOk(r) { return !!(r && r.rank && r.of && r.rank <= 10 && r.rank <= Math.ceil(r.of / 2)); }

// The two headline lines, only from what shelf_month() can show is true.
function crdMonthHeadline(m) {
  const n = m.added || 0, up = m.tier_ups || {}, at = m.added_tiers || {};
  const rise = (m.value_now || 0) - (m.value_then || 0);
  const second = (() => {
    if (up.grail) return crdWord(up.grail) + " went Grail.";
    if (up.rare) return crdWord(up.rare) + " went Rare.";
    if (n > 0 && at.grail) return at.grail === 1 ? "One is a Grail." : crdWord(at.grail) + " are Grails.";
    if (n > 0 && at.rare) return at.rare === 1 ? "One is Rare." : crdWord(at.rare) + " are Rare.";
    if (m.mover) return "One rose " + m.mover.pct + "%.";
    if (n > 0 && at.scarce) return at.scarce === 1 ? "One is Scarce." : crdWord(at.scarce) + " are Scarce.";
    if (m.value_then && rise >= 10) return "Up " + crdGbp(rise) + ".";
    return "";
  })();
  if (n > 0) return ["+" + n.toLocaleString("en-GB") + " record" + (n === 1 ? "" : "s") + ".", second];
  // Nothing added: a record going up a tier is the month's news, ahead of any
  // price move. Without these a month whose one event was a Grail was never
  // offered at all (QA 29 Sep 2026). Mirrored in lib/month-report.js.
  if (up.grail) return [up.grail === 1 ? "One record" : crdWord(up.grail) + " records", "went Grail."];
  if (up.rare) return [up.rare === 1 ? "One record" : crdWord(up.rare) + " records", "went Rare."];
  if (m.mover) return ["One record", "rose " + m.mover.pct + "%."];
  if (m.value_then && rise >= 10) return ["The shelf rose", crdGbp(rise) + "."];
  return null;
}

async function crdRenderMonth(card, who) {
  const m = card.data.month, mv = m.mover;
  const canvas = document.createElement("canvas"); canvas.width = CRD_W; canvas.height = CRD_H;
  const ctx = crdBase(canvas);
  crdHeader(ctx);
  const img = mv ? await crdCover(Object.assign({ coverUrl: card.data.moverCover || null }, mv)) : null;
  const head = crdMonthHeadline(m) || [m.month + ".", ""];
  // Kicker, headline, mover, tiles, note: centred between header and footer.
  const moverH = mv ? 330 + 52 : 0;
  const blockH = 60 + 130 * (head[1] ? 2 : 1) + 40 + moverH + 210 + 80;
  let y = Math.max(290, 150 + (CRD_H - 300 - blockH) / 2 + 26);
  ctx.fillStyle = CRD.KICK; ctx.font = "500 26px 'JetBrains Mono', monospace";
  crdSpaced(ctx, (m.month + " on the shelf").toUpperCase(), 72, y, 3);
  y += 130;
  ctx.fillStyle = CRD.PAPER; ctx.font = "400 116px 'DM Serif Display', Georgia, serif";
  head.filter(Boolean).forEach((ln, k) => {
    const t = window.bdWrap ? window.bdWrap(ctx, ln, CRD_W - 144, 1)[0] : ln;
    ctx.fillText(t, 72, y + k * 128);
  });
  y += (head[1] ? 128 : 0) + 70;
  if (mv) {
    // The mover, on the card's raised panel.
    const px = 72, pw = CRD_W - 144, ph = 330;
    ctx.fillStyle = CRD.INK2; ctx.fillRect(px, y, pw, ph);
    const S = 240, sx = px + 44, sy = y + (ph - S) / 2;
    crdSleeve(ctx, img, mv, sx, sy, S, card.data.moverTier || null);
    const tx = sx + S + 48, tw = px + pw - 40 - tx;
    let ty = y + 96;
    ctx.fillStyle = CRD.PAPER; ctx.font = "italic 400 52px 'DM Serif Display', Georgia, serif";
    const tl = window.bdWrap ? window.bdWrap(ctx, mv.title || "", tw, 2) : [mv.title || ""];
    tl.forEach((ln, k) => ctx.fillText(ln, tx, ty + k * 56));
    ty += (tl.length - 1) * 56 + 48;
    ctx.fillStyle = CRD.PAPER2; ctx.font = "500 22px 'JetBrains Mono', monospace";
    const det = [mv.artist, crdDetails(mv)].filter(Boolean).join(" · ");
    ctx.fillText(window.bdWrap ? window.bdWrap(ctx, det, tw, 1)[0] : det, tx, ty);
    ty += 74;
    ctx.fillStyle = CRD.GAIN; ctx.font = "800 54px 'Inter', Helvetica, sans-serif";
    ctx.fillText("▲ " + mv.pct + "% · " + crdGbp(mv.to), tx, ty);
    y += ph + 52;
  }
  // Two tiles: the shelf's value, and its standing (or its size).
  const gap = 24, tw2 = (CRD_W - 144 - gap) / 2, th = 190;
  const tile = (x, big, small) => {
    ctx.strokeStyle = CRD.RULE; ctx.lineWidth = 2; ctx.strokeRect(x + 1, y + 1, tw2 - 2, th - 2);
    ctx.fillStyle = CRD.PAPER; ctx.font = "800 66px 'Inter', Helvetica, sans-serif";
    ctx.fillText(big, x + 32, y + 96);
    ctx.fillStyle = CRD.MUTE; ctx.font = "500 21px 'JetBrains Mono', monospace";
    const sm = window.bdWrap ? window.bdWrap(ctx, small.toUpperCase(), tw2 - 64, 2) : [small.toUpperCase()];
    sm.forEach((ln, k) => ctx.fillText(ln, x + 32, y + 138 + k * 28));
  };
  const valNow = typeof m.value_now === "number" ? m.value_now : card.data.total;
  const rise = (m.value_now || 0) - (m.value_then || 0);
  const since = m.then_on ? new Date(m.from + "T00:00:00Z").toLocaleDateString("en-GB", { day: "numeric", month: "short", timeZone: "UTC" }) : null;
  tile(72, crdGbp(valNow), m.value_then && rise >= 10 && since ? "shelf value, up " + crdGbp(rise) + " since " + since : "shelf value");
  const rn = m.rank_now, rt = m.rank_then;
  if (crdStandingOk(rn)) {
    tile(72 + tw2 + gap, crdOrdinal(rn.rank), "by value" + (rt && rt.rank > rn.rank ? ", up from " + crdOrdinal(rt.rank) : " of " + rn.of + " collectors"));
  } else {
    tile(72 + tw2 + gap, (card.data.n || 0).toLocaleString("en-GB"), card.data.n === 1 ? "record on the shelf" : "records on the shelf");
  }
  y += th + 58;
  if (mv) {
    ctx.fillStyle = CRD.MUTE2; ctx.font = "500 20px 'JetBrains Mono', monospace";
    ctx.fillText("A record is named only when it rises 15% and £10 in the month.", 72, y);
  }
  crdFooter(ctx, who.path);
  return canvas;
}

const CRD_RENDER = { ten: crdRenderTen, grail: crdRenderRarest, dig: crdRenderDig, month: crdRenderMonth };

// ---- the 1200x630 twins (F9 part 3; card_twins.sql) -----------------------
// What a card's own link unfurls as in WhatsApp, iMessage, X and the rest:
// the same card, landscape. The art on the left, the words on the right,
// the shelf's address at the foot. Stored like the shelf preview (ShelfShare
// drawAndStore): drawn here, uploaded to shelf-og, recorded with the identity
// it was drawn for by set_shelf_card_og(), and served by the /u/ handler only
// while that identity is still the shelf's.
const TW_W = 1200, TW_H = 630, TW_MAX_BYTES = 290 * 1024;

function twBase() {
  const canvas = document.createElement("canvas"); canvas.width = TW_W; canvas.height = TW_H;
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = CRD.INK; ctx.fillRect(0, 0, TW_W, TW_H);
  return { canvas, ctx };
}
// The right-hand column: mark and wordmark, then the caller's words, then the link.
const TW_X = 640, TW_R = TW_W - 56;
function twFrame(ctx, path) {
  if (window.bdDrawMark) { ctx.save(); ctx.translate(TW_X, 58); ctx.scale(0.5, 0.5); window.bdDrawMark(ctx, 0, 0, CRD.ACCENT, CRD.PAPER); ctx.restore(); }
  ctx.font = "italic 400 28px 'DM Serif Display', Georgia, serif"; ctx.fillStyle = CRD.MUTE;
  ctx.fillText("The", TW_X + 52, 84);
  const tw = ctx.measureText("The ").width;
  ctx.font = "400 28px 'DM Serif Display', Georgia, serif"; ctx.fillStyle = CRD.PAPER;
  ctx.fillText("Lead-In", TW_X + 52 + tw, 84);
  ctx.strokeStyle = CRD.RULE; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(TW_X, TW_H - 104); ctx.lineTo(TW_R, TW_H - 104); ctx.stroke();
  ctx.fillStyle = CRD.KICK; ctx.font = "500 20px 'JetBrains Mono', monospace";
  let l = "theleadin.com" + (path || "");
  while (l.length > 16 && ctx.measureText(l).width > TW_R - TW_X) l = l.slice(0, -2);
  ctx.fillText(l, TW_X, TW_H - 62);
}
function twKicker(ctx, text, y) {
  ctx.fillStyle = CRD.KICK; ctx.font = "500 18px 'JetBrains Mono', monospace";
  crdSpaced(ctx, String(text).toUpperCase(), TW_X, y, 2);
}
function twLines(ctx, lines, y, size, lh, font) {
  ctx.fillStyle = CRD.PAPER; ctx.font = (font || "400") + " " + size + "px 'DM Serif Display', Georgia, serif";
  const out = [];
  lines.filter(Boolean).forEach((ln) => {
    const w = window.bdWrap ? window.bdWrap(ctx, ln, TW_R - TW_X, 2) : [ln];
    w.forEach((x) => out.push(x));
  });
  out.slice(0, 3).forEach((ln, k) => ctx.fillText(ln, TW_X, y + k * lh));
  return y + (Math.min(out.length, 3) - 1) * lh;
}
// One big sleeve on the left, square, with its tier.
async function twHero(ctx, rec, tier) {
  const img = rec ? await crdCover(rec) : null;
  const S = 500, x = 60, y = (TW_H - S) / 2;
  crdSleeve(ctx, img, rec, x, y, S, tier);
}

async function twTen(card, who) {
  const d = card.data, { canvas, ctx } = twBase();
  const nine = d.front.slice(0, 9);
  const imgs = await Promise.all(nine.map(crdCover));
  const CELL = 164, GAP = 16, gx = 52, gy = (TW_H - (3 * CELL + 2 * GAP)) / 2;
  for (let i = 0; i < nine.length; i++) {
    crdSleeve(ctx, imgs[i], nine[i], gx + (i % 3) * (CELL + GAP), gy + Math.floor(i / 3) * (CELL + GAP), CELL, d.tierOf(nine[i]));
  }
  twFrame(ctx, who.path);
  twKicker(ctx, d.chosen ? "My shelf · my ten" : "My shelf · top ten", 190);
  const y = twLines(ctx, [who.first + "’s ten"], 262, 64, 70);
  let cx = TW_X; const c = d.counts || {};
  if (c.grail) cx += crdChip(ctx, cx, y + 48, c.grail + " Grail", "grail", { size: 18 }) + 12;
  if (c.rare) cx += crdChip(ctx, cx, y + 48, c.rare + " Rare", "rare", { size: 18 }) + 12;
  crdChip(ctx, cx, y + 48, d.n.toLocaleString("en-GB") + " record" + (d.n === 1 ? "" : "s"), null, { size: 18 });
  return canvas;
}
async function twRarest(card, who) {
  const d = card.data, r = d.rec, { canvas, ctx } = twBase();
  await twHero(ctx, r, d.tier);
  twFrame(ctx, who.path);
  crdChip(ctx, TW_X, 150, CRD_TIER_WORD[d.tier] + (d.topShare ? " · top " + d.topShare : ""), d.tier, { size: 18 });
  let y = twLines(ctx, [r.title || ""], 268, 54, 60, "italic 400");
  ctx.fillStyle = CRD.PAPER2; ctx.font = "600 24px 'Inter', Helvetica, sans-serif";
  ctx.fillText(String(r.artist || ""), TW_X, y + 46);
  if (r.value > 0) { ctx.fillStyle = CRD.PAPER; ctx.font = "800 64px 'Inter', Helvetica, sans-serif"; ctx.fillText(crdGbp(r.value), TW_X, y + 132); }
  return canvas;
}
async function twDig(card, who) {
  const d = card.data, r = d.rec, { canvas, ctx } = twBase();
  await twHero(ctx, r, d.tier);
  twFrame(ctx, who.path);
  twKicker(ctx, "My best dig", 170);
  let y = twLines(ctx, [r.title || ""], 244, 54, 60, "italic 400");
  ctx.fillStyle = CRD.MUTE; ctx.font = "800 44px 'Inter', Helvetica, sans-serif";
  const paid = crdGbp(d.price); ctx.fillText(paid, TW_X, y + 84);
  const pw = ctx.measureText(paid).width;
  ctx.font = "400 44px 'DM Serif Display', Georgia, serif"; ctx.fillText("→", TW_X + pw + 24, y + 82);
  ctx.fillStyle = CRD.PAPER; ctx.font = "800 60px 'Inter', Helvetica, sans-serif"; ctx.fillText(crdGbp(d.value), TW_X + pw + 86, y + 86);
  const mult = d.value / d.price;
  ctx.fillStyle = CRD.GAIN; ctx.font = "400 44px 'DM Serif Display', Georgia, serif";
  ctx.fillText(mult >= 2 ? (mult >= 10 ? Math.round(mult) : Math.round(mult * 10) / 10) + "× what I paid" : "+" + d.pct + "% on what I paid", TW_X, y + 150);
  return canvas;
}
// The month itself, where a record would go: a calendar page, as the story card
// has no record without a mover either.
function twMonthPanel(ctx, m) {
  const S = 500, x = 60, y = (TW_H - S) / 2;
  ctx.fillStyle = CRD.INK2; ctx.fillRect(x, y, S, S);
  ctx.fillStyle = CRD.ACCENT; ctx.fillRect(x, y, S, 22);
  ctx.textAlign = "center";
  ctx.fillStyle = CRD.PAPER; ctx.font = "400 210px 'DM Serif Display', Georgia, serif";
  ctx.fillText(String(m.month || "").slice(0, 3).toUpperCase(), x + S / 2, y + 318);
  ctx.fillStyle = CRD.MUTE; ctx.font = "500 30px 'JetBrains Mono', monospace";
  crdSpaced(ctx, String(m.from || "").slice(0, 4), x + S / 2, y + 392, 6, "center");
  ctx.textAlign = "left";
}
async function twMonth(card, who) {
  const d = card.data, m = d.month, mv = m.mover, { canvas, ctx } = twBase();
  if (mv) await twHero(ctx, Object.assign({ coverUrl: d.moverCover || null }, mv), d.moverTier);
  else twMonthPanel(ctx, m);
  twFrame(ctx, who.path);
  twKicker(ctx, m.month + " on the shelf", 170);
  const head = crdMonthHeadline(m) || [m.month + "."];
  let y = twLines(ctx, head, 244, 58, 64);
  if (mv) { ctx.fillStyle = CRD.GAIN; ctx.font = "800 34px 'Inter', Helvetica, sans-serif"; ctx.fillText("▲ " + mv.pct + "% · " + crdGbp(mv.to), TW_X, y + 72); }
  return canvas;
}
const TW_RENDER = { ten: twTen, grail: twRarest, dig: twDig, month: twMonth };

// What the twin shows, reduced to a string: when it is unchanged, the twin
// already recorded is still true and is reused, not redrawn and re-uploaded.
function crdSig(card, who) {
  const d = card.data || {}, h = window.shHash || ((x) => String(x).length.toString(36));
  const body = card.kind === "ten" ? d.front.slice(0, 9).map((r) => r.id).join(",") + "|" + JSON.stringify(d.counts || {}) + "|" + d.n
    : card.kind === "grail" ? [d.rec && d.rec.id, d.tier, Math.round((d.rec && d.rec.value) || 0), d.topShare].join("|")
    : card.kind === "dig" ? [d.rec && d.rec.id, Math.round(d.price || 0), Math.round(d.value || 0)].join("|")
    // The month twin prints the headline, the mover's rise and price, and a
    // cover: sign exactly those, or a twin saying "Up £56" outlives the rise.
    : (() => {
        const m = d.month || {}, mv = m.mover;
        return JSON.stringify([m.month, crdMonthHeadline(m), mv && mv.id, mv && mv.pct, mv && Math.round(mv.to || 0),
                               mv ? d.moverTier || null : (card.cover && card.cover.id) || null]);
      })();
  // t2 (30 Sep 2026): the real brand mark, and the month twin's own panel. Every
  // twin drawn before is redrawn on its next share rather than reused.
  return ["t2", card.kind, who.path || "", h(String(who.label || "")), h(body)].join("~");
}

// who: { uid, mode: "named"|"anon", alias, label, first, path, cards } where
// `cards` is the profile's shelf_cards. Resolves true once the card's link
// will unfurl as its twin (reused or freshly recorded), false if it won't.
async function crdStoreTwin(card, who) {
  const sb = window.BBR_supabase;
  if (!sb || !who || !who.uid || !TW_RENDER[card.kind]) return false;
  const tr = (q) => Promise.resolve(q).then((x) => x, () => null);
  const sig = crdSig(card, who);
  const prev = who.cards && who.cards[card.kind];
  if (prev && prev.sig === sig && prev.path &&
      ((who.mode === "named" && prev.mode === "named") || (who.mode === "anon" && prev.mode === "anon" && prev.alias === who.alias))) return true;
  await crdFonts();
  const canvas = await TW_RENDER[card.kind](card, who);
  let blob = null;
  for (const q of [0.86, 0.76, 0.64]) {
    blob = await new Promise((res) => canvas.toBlob(res, "image/jpeg", q));
    if (!blob || blob.size <= TW_MAX_BYTES) break;
  }
  if (!blob) return false;
  const name = who.uid + "/" + (window.shRandomName ? window.shRandomName() : Math.random().toString(36).slice(2).padEnd(20, "0")) + ".jpg";
  const up = await tr(sb.storage.from("shelf-og").upload(name, blob, { contentType: "image/jpeg", upsert: false, cacheControl: "31536000" }));
  if (!up || up.error) return false;
  const stamp = await tr(sb.rpc("set_shelf_card_og", {
    p_card: card.kind, p_path: name, p_sig: sig, p_mode: who.mode,
    p_alias: who.mode === "anon" ? (who.alias || null) : null,
    p_name: who.mode === "named" ? who.label : null,
  }));
  const ok = !!(stamp && !stamp.error && stamp.data && stamp.data.ok);
  // Tidy: the twin this one replaced, or this one if it was refused.
  const stale = ok ? (stamp.data.prev && stamp.data.prev !== name ? stamp.data.prev : null) : name;
  if (stale) tr(sb.storage.from("shelf-og").remove([stale]));
  if (ok && who.cards) who.cards[card.kind] = stamp.data.card;
  return ok;
}

// ---- which cards this shelf can make ------------------------------------------

// "0.2%" for the share of priced pressings at or above a tier, rounded UP so
// the claim is never better than the truth; null when the counts are unknown.
function crdTopShare(counts, tier) {
  if (!counts) return null;
  const total = (counts.grail || 0) + (counts.rare || 0) + (counts.scarce || 0) + (counts.common || 0);
  const at = tier === "grail" ? (counts.grail || 0) : tier === "rare" ? (counts.grail || 0) + (counts.rare || 0)
    : (counts.grail || 0) + (counts.rare || 0) + (counts.scarce || 0);
  if (!(total > 0) || !(at > 0)) return null;
  const pct = (at / total) * 100;
  // One decimal under 10% ("top 1.1%"), whole numbers above; always rounded up.
  const up = pct < 10 ? Math.ceil(pct * 10) / 10 : Math.ceil(pct);
  return up + "%";
}

// records: the owner's, as ShelfShare holds them, each carrying the fields its
//   card prints (label, catalogue_no, media_condition, purchase_price, is_gift).
// tierOf(record) -> 'grail' | 'rare' | 'scarce' | null; nmOf(record) -> number.
// siteCounts: every priced pressing's tier count, for "top 0.2%".
// focusId: a record to lead the Grail card with, if it has a tier (the one just
//   added, when the sheet was opened from the Grail moment).
// month: shelf_month()'s answer, or null while it loads or when unavailable.
function crdCards({ records, front, chosen, tierOf, nmOf, counts, siteCounts, focusId, month }) {
  const out = [];
  const recs = (records || []).filter((r) => r && (r.title || r.artist));
  if (!recs.length) return out;
  const f = (front && front.length ? front : recs).slice(0, 9);
  out.push({ kind: "ten", title: "Your ten", sub: "Story · 9:16", cover: f[0] || null,
             data: { front: f, chosen: !!chosen, tierOf, counts, n: recs.length } });
  // The best-tiered pressing, then the most valuable of that tier.
  const rank = (r) => (window.tierRank ? window.tierRank(tierOf(r)) : 0);
  const focus = focusId ? recs.find((r) => String(r.id) === String(focusId) && tierOf(r)) : null;
  const top = focus || recs.filter((r) => tierOf(r)).sort((a, b) => rank(b) - rank(a) || (Number(b.value) || 0) - (Number(a.value) || 0))[0];
  if (top) {
    const t = tierOf(top);
    out.push({ kind: "grail", title: t === "grail" ? "Your Grail" : "Your rarest", sub: "Story · 9:16", cover: top,
               data: { rec: top, tier: t, topShare: crdTopShare(siteCounts, t), nm: nmOf ? nmOf(top) : null } });
  }
  // The month: offered only when there is something true to say about it.
  if (month && crdMonthHeadline(month)) {
    const mv = month.mover;
    const own = mv ? recs.find((r) => String(r.id) === String(mv.id)) : null;
    // Its cover is its mover or nothing. It used to fall back to f[0], the front
    // ten's first, which by default is the rarest record: the Grail card's own.
    // Both tiles and both link twins showed the same sleeve (reported 30 Sep
    // 2026). Without a mover the tile and the twin show the month itself.
    out.push({ kind: "month", title: month.month, sub: "Month card", cover: own || mv || null,
               data: { month, moverCover: own ? own.coverUrl || null : null, moverTier: own ? tierOf(own) : null,
                       total: recs.reduce((t, r) => t + (Number(r.value) || 0), 0), n: recs.length } });
  }
  const dig = typeof window.computeBestDig === "function" ? window.computeBestDig(recs) : null;
  if (dig && dig.rec) {
    out.push({ kind: "dig", title: "Best dig", sub: "Story · 9:16", cover: dig.rec,
               data: { rec: dig.rec, price: dig.price, value: dig.value, pct: dig.pct, tier: tierOf(dig.rec) } });
  }
  return out;
}

// who: { label, first, path } from the share sheet's identity.
async function crdRender(card, who) {
  await crdFonts();
  const fn = CRD_RENDER[card.kind];
  if (!fn) throw new Error("unknown card " + card.kind);
  return fn(card, who);
}

// Share the image with the link, or save it where files can't be shared. The
// link is the shelf's, tagged with the card so its landings and signups count
// against it. Returns "file" | "download" | "cancelled".
async function crdSend(card, canvas, who) {
  const blob = await new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.92));
  if (!blob) throw new Error("toBlob");
  const name = "the-lead-in-" + card.kind + ".jpg";
  // The card's own link: /u/<handle>?c=<kind> unfurls as its twin (part 3).
  const base = CRD_SITE + (who.path || "/") + "?c=" + card.kind;
  const link = window.BBR_ref ? await window.BBR_ref.link(base, "card_" + card.kind) : base;
  const text = card.kind === "ten" ? "My ten on The Lead-In:"
    : card.kind === "grail" ? (card.data.tier === "grail" ? "My Grail on The Lead-In:" : "My rarest record on The Lead-In:")
    : card.kind === "month" ? "My " + card.data.month.month + " on The Lead-In:"
    : "My best dig on The Lead-In:";
  const track = (method) => { try { window.BBR_ref && window.BBR_ref.track("card_share", { card: card.kind, method }); } catch (e) {} };
  const file = typeof File === "function" ? new File([blob], name, { type: "image/jpeg" }) : null;
  if (file && navigator.canShare && navigator.canShare({ files: [file] }) && navigator.share) {
    try { await navigator.share({ files: [file], text: text + " " + link }); track("file"); return "file"; }
    catch (e) { if (e && e.name === "AbortError") return "cancelled"; }
  }
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob); a.download = name;
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(a.href), 4000);
  track("download");
  return "download";
}

// The shelf's identity for a card shared from outside the sheet (Best Dig on
// the collection page): the same handle, label and path ShelfShare resolves,
// by the same rules, so a card never names someone the shelf doesn't.
async function crdWho() {
  const sb = window.BBR_supabase;
  if (!sb) return null;
  const tr = (q) => Promise.resolve(q).then((x) => x, () => null);
  const sess = await tr(sb.auth.getSession());
  const uid = sess && sess.data && sess.data.session && sess.data.session.user && sess.data.session.user.id;
  if (!uid) return null;
  const eh = await tr(sb.rpc("ensure_shelf_handle"));
  const h = eh && eh.data && eh.data.ok ? eh.data : null;
  const pr = await tr(sb.from("profiles").select("display_name,is_public,anon_alias,shelf_cards").eq("id", uid).maybeSingle());
  const p = (pr && pr.data) || {};
  const k = h ? h.kind : (p.is_public ? "named" : "anon");
  const label = k === "named" ? (String(p.display_name || "").trim() || "A collector")
                              : (p.anon_alias ? "Anonymous " + p.anon_alias : "An anonymous collector");
  return { label, first: k === "named" ? (label.split(/\s+/)[0] || label) : label, kind: k, mode: k,
           uid, alias: p.anon_alias || null, cards: p.shelf_cards || {},
           path: h && h.handle ? "/u/" + h.handle : "/collector/" + uid };
}

Object.assign(window, { BBR_cards: { cards: crdCards, render: crdRender, send: crdSend, topShare: crdTopShare, who: crdWho, storeTwin: crdStoreTwin, sig: crdSig } });
