// ===========================================================
// AlbumExtras, deep-dive page enhancements.
// Standalone pieces wired into AlbumPage:
//   • bbrAffiliateFor, shared retailer/price logic
//   • StickyBuyBar, floating buy bar once the affiliate strip scrolls away
//   • MobileDossierNav, sticky horizontal section pills (< 1180px)
//   • MobileTOC, collapsible table of contents under the hero (< 768px)
//   • RecLink, actionable "if you love this" recommendation
//   • AlbumAccent, per-album accent colour derived from the cover art
// Loaded after Sleeve.jsx, before AlbumPage.jsx.
// ===========================================================
const { useState: useXState, useEffect: useXEffect, useRef: useXRef, useMemo: useXMemo } = React;
const XSleeve = window.Sleeve;

/* ---- shared affiliate logic (mirrors AffiliateStrip) ---- */
function bbrAffiliateFor(album) {
  const q = encodeURIComponent(album.artist + " " + album.title + " vinyl");
  const href = window.BBR_buyUrl ? window.BBR_buyUrl(album.artist + " " + album.title, album.slug) : "https://www.amazon.co.uk/s?k=" + q + "&i=popular&tag=theleadin1-21";
  const retailer = (window.BBR_retailerFor && window.BBR_retailerFor(href)) || "vinyl";
  const primary = { name: retailer, label: "New vinyl", href };
  const retailers = [primary];
  const best = retailers[0];
  return { retailers, best };
}
window.bbrAffiliateFor = bbrAffiliateFor;

/* ---- StickyBar, the floating rail that follows the reader down a dossier ----
   Was a buy bar ("Buy on vinyl" → Amazon/Rough Trade). It is now an ADD bar, and
   that is the point: the Ambassador Programme promises record shops there is no buy
   button anywhere on the site (see the note on window.BBR_COMMERCE_ENABLED in
   data/collection_sample.js), so the same piece of furniture that used to send a
   reader to a retailer now puts the record on their own shelf instead.

   The chrome is unchanged — cover, title, artist, dismiss — because the design was
   already right; only the action moved. The action itself is <TodayAdd/>, the same
   component the /today bar mounts, so owned-state, the guest cap, the saving state
   and the error copy live in exactly one place across every add surface on the site.

   When commerce is switched back on this reverts to the buy CTA on its own. */
function StickyBuyBar({ album }) {
  const store = window.BBR_store;
  const [visible, setVisible] = useXState(false);
  const [dismissed, setDismissed] = useXState(false);
  const [ownedAtLoad, setOwnedAtLoad] = useXState(null);
  const [, forceX] = useXState(0);
  const commerce = !!window.BBR_COMMERCE_ENABLED;
  const { best } = bbrAffiliateFor(album);

  useXEffect(() => { setDismissed(false); setOwnedAtLoad(null); }, [album.slug]);
  useXEffect(() => (store ? store.subscribe(() => forceX((n) => n + 1)) : undefined), []);

  useXEffect(() => {
    const onScroll = () => {
      // The affiliate strip is gone while commerce is off, so fall through to the
      // plain scroll threshold this always used when the strip was absent.
      const strip = document.querySelector(".aff-strip");
      if (!strip) { setVisible(window.scrollY > 700); return; }
      setVisible(strip.getBoundingClientRect().bottom < 8);
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener("scroll", onScroll);
  }, [album.slug, commerce]);

  // Same discipline as the /today bar: settle "did they already own this" only once
  // the collection has actually resolved, and then never offer to add it again. A
  // bar telling a collector something is already on their shelf is furniture.
  const loaded = !!(store && store.collectionV2Loaded);
  const owned = !!(store && store.ownedSlugV2 && store.ownedSlugV2(album.slug));
  useXEffect(() => {
    if (!commerce && loaded && ownedAtLoad === null) setOwnedAtLoad(owned);
  }, [commerce, loaded, owned, ownedAtLoad]);

  if (dismissed) return null;
  if (!commerce && (!loaded || ownedAtLoad !== false)) return null;

  return (
    <div className={"buybar" + (visible ? " visible" : "") + (commerce ? "" : " buybar--add")} aria-hidden={!visible}>
      <div className="buybar-cover">{XSleeve && <XSleeve album={album} size={36} />}</div>
      <div className="buybar-info">
        <span className="buybar-title">{album.title}</span>
        <span className="buybar-sub">{album.artist} · {album.year}</span>
      </div>
      {commerce ? (
        <a className="buybar-cta" href={best.href} target="_blank" rel="sponsored noopener" onClick={() => window.BBR_trackClick && window.BBR_trackClick(album.slug, "floating")}>
          Buy on vinyl
          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true"><path d="M7 17L17 7M9 7h8v8" /></svg>
        </a>
      ) : (window.TodayAdd ? <TodayAdd album={album} compact /> : null)}
      <button className="buybar-x" onClick={() => setDismissed(true)} aria-label="Dismiss">×</button>
    </div>
  );
}
window.StickyBuyBar = StickyBuyBar;

/* ---- scroll helper that accounts for sticky chrome ---- */
function bbrScrollToId(id, extra) {
  const el = document.getElementById(id);
  if (!el) return;
  // Scroll so the section's heading sits just below the fixed bars. We target
  // the heading element specifically (the section box top has padding + the
  // stacked label above the heading, so aligning the box top overshoots/
  // undershoots). Compute an absolute target and use a single scrollTo —
  // scrollIntoView + smooth was racing the scroll-spy and overshooting.
  const heading = el.querySelector("h2") || el;
  const fixedBars = (() => {
    const nav = document.querySelector(".topnav");
    const toc = document.querySelector(".mtoc");
    const navH = nav ? nav.getBoundingClientRect().height : 56;
    const tocH = (toc && getComputedStyle(toc).position === "sticky") ? toc.getBoundingClientRect().height : 0;
    return navH + tocH + 12; // small breathing gap
  })();
  const target = heading.getBoundingClientRect().top + window.scrollY - fixedBars;
  window.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
}

/* ---- MobileDossierNav, sticky horizontal pill bar (< 1180px) ---- */
function MobileDossierNav({ sections }) {
  const [active, setActive] = useXState(sections[0] && sections[0].id);
  const [visible, setVisible] = useXState(false);
  const railRef = useXRef(null);

  useXEffect(() => {
    const onScroll = () => setVisible(window.scrollY > 420);
    window.addEventListener("scroll", onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  useXEffect(() => {
    if (!sections.length) return;
    const observer = new IntersectionObserver((entries) => {
      const hit = entries.filter(e => e.isIntersecting);
      if (!hit.length) return;
      hit.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
      setActive(hit[0].target.id);
    }, { rootMargin: "-30% 0px -55% 0px", threshold: 0 });
    sections.forEach(s => { const el = document.getElementById(s.id); if (el) observer.observe(el); });
    return () => observer.disconnect();
  }, [sections]);

  // keep the active pill scrolled into the centre of the rail
  useXEffect(() => {
    const rail = railRef.current;
    if (!rail) return;
    const el = rail.querySelector(".mdnav-pill.active");
    if (el) {
      const target = el.offsetLeft - rail.clientWidth / 2 + el.clientWidth / 2;
      rail.scrollTo({ left: Math.max(0, target), behavior: "smooth" });
    }
  }, [active]);

  if (!sections.length) return null;

  return (
    <nav className={"mdnav" + (visible ? " visible" : "")} aria-label="Dossier sections">
      <div className="mdnav-rail" ref={railRef}>
        {sections.map(s => (
          <button
            key={s.id}
            className={"chip mdnav-pill" + (active === s.id ? " active" : "")}
            onClick={() => bbrScrollToId(s.id, 120)}
          >
            <span className="mdnav-num">{s.numeral.toLowerCase()}</span>
            {s.label}
          </button>
        ))}
      </div>
    </nav>
  );
}
window.MobileDossierNav = MobileDossierNav;

/* ---- MobileTOC, collapsible contents card under the hero (< 768px) ---- */
function MobileTOC({ sections, minutes }) {
  const [open, setOpen] = useXState(false);
  const [active, setActive] = useXState(sections[0] && sections[0].id);
  const rootRef = useXRef(null);

  // Scroll-spy: track which section is currently in view so the collapsed
  // bar always shows where you are.
  useXEffect(() => {
    if (!sections.length) return;
    const observer = new IntersectionObserver((entries) => {
      const hit = entries.filter(e => e.isIntersecting);
      if (!hit.length) return;
      hit.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
      setActive(hit[0].target.id);
    }, { rootMargin: "-25% 0px -60% 0px", threshold: 0 });
    sections.forEach(s => { const el = document.getElementById(s.id); if (el) observer.observe(el); });
    return () => observer.disconnect();
  }, [sections]);

  // Close the dropdown on outside tap / Escape.
  useXEffect(() => {
    if (!open) return;
    const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("pointerdown", onDown);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
  }, [open]);

  // All hooks above this line — only now is it safe to bail out.
  if (!sections.length) return null;

  const current = sections.find(s => s.id === active) || sections[0];

  return (
    <div className={"mtoc" + (open ? " open" : "")} ref={rootRef}>
      <button className="mtoc-toggle" onClick={() => setOpen(o => !o)} aria-expanded={open} aria-label="Jump to section">
        <span className="mtoc-eyebrow">Section</span>
        <span className="mtoc-current"><span className="mtoc-current-num">{current.numeral}</span>{current.label}</span>
        <svg className="mtoc-chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
      </button>
      <div className="mtoc-list">
        <div className="mtoc-list-head">{sections.length} sections · {minutes} min read</div>
        {sections.map(s => (
          <button
            key={s.id}
            className={"mtoc-item" + (s.id === active ? " active" : "")}
            onClick={() => { setOpen(false); bbrScrollToId(s.id, 120); }}
          >
            <span className="mtoc-num">{s.numeral}</span>
            <span className="mtoc-label">{s.label}</span>
          </button>
        ))}
      </div>
    </div>
  );
}
window.MobileTOC = MobileTOC;

/* ---- recommendation linking ---- */
function bbrNorm(s) { return (s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); }
function resolveRecAlbum(title, all) {
  if (!all) return null;
  const namePart = String(title).split(/\s+[, –-]\s+/)[0].replace(/\s*\(\d{4}\)\s*$/, "").trim();
  const target = bbrNorm(namePart);
  if (!target) return null;
  // Exact match first; then a conservative prefix match. The second branch used
  // to repeat the exact `=== target` test, so it could never match anything the
  // first didn't — the intended fuzzy match was dead. The length guards keep
  // short/common titles from mis-resolving.
  return all.find(a => bbrNorm(a.title) === target) ||
         (target.length >= 6 && all.find(a => { const t = bbrNorm(a.title); return t.length >= 6 && t.startsWith(target); })) || null;
}

function RecLink({ rec, all, onAlbum }) {
  const match = resolveRecAlbum(rec.title, all);
  const hasEssay = match && window.ESSAYS_FULL && window.ESSAYS_FULL[match.slug];

  if (match) {
    return (
      <a
        className="rec rec-link"
        href="#"
        onClick={(e) => { e.preventDefault(); onAlbum(match); }}
      >
        <div className="rec-title">{rec.title}</div>
        <div className="rec-why" dangerouslySetInnerHTML={{ __html: rec.why }} />
        <span className="rec-action onsite">
          {hasEssay ? "Read the essay" : "See the entry"}
          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4"><path d="M5 12h14M13 5l7 7-7 7" /></svg>
        </span>
      </a>
    );
  }

  const cleanTitle = String(rec.title).replace(/\(\d{4}\)/g, "").replace(/[, –]/g, " ").trim();
  const q = encodeURIComponent(cleanTitle + " vinyl");
  const href = (window.BBR_buyUrl) ? window.BBR_buyUrl(cleanTitle, rec.slug) : "https://www.amazon.co.uk/s?k=" + q + "&i=popular&tag=theleadin1-21";

  // This branch is the recommendation we have NO page for, so with commerce off
  // there is nowhere honest to send the reader — the record simply is not on the
  // site. It becomes a plain card: the recommendation and the reason for it still
  // read exactly as before, they just stop being a link to a retailer.
  if (!window.BBR_COMMERCE_ENABLED) {
    return (
      <div className="rec rec-note">
        <div className="rec-title">{rec.title}</div>
        <div className="rec-why" dangerouslySetInnerHTML={{ __html: rec.why }} />
      </div>
    );
  }
  return (
    <a className="rec rec-link" href={href} target="_blank" rel="sponsored noopener" onClick={() => window.BBR_trackClick && window.BBR_trackClick(rec.slug, "recommendation")}>
      <div className="rec-title">{rec.title}</div>
      <div className="rec-why" dangerouslySetInnerHTML={{ __html: rec.why }} />
      <span className="rec-action external">
        Own it on vinyl
        <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true"><path d="M7 17L17 7M9 7h8v8" /></svg>
      </span>
    </a>
  );
}
window.RecLink = RecLink;

/* ---- per-album accent extraction from the cover art ----
   Pulls the dominant *vibrant* hue from covers/<rank>.jpg, then rebuilds it
   at a fixed, on-brand chroma/lightness (so every accent stays cohesive and
   always clears WCAG AA against the light paper background). Falls back to the
   default vermillion if the cover is grayscale, fails to load, or is unreadable. */
function rgb2hsl(r, g, b) {
  r /= 255; g /= 255; b /= 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  let h = 0, s = 0; const l = (max + min) / 2;
  const d = max - min;
  if (d !== 0) {
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    if (max === r) h = ((g - b) / d + (g < b ? 6 : 0));
    else if (max === g) h = (b - r) / d + 2;
    else h = (r - g) / d + 4;
    h *= 60;
  }
  return [h, s, l];
}

function extractCoverHue(slug, cb) {
  const img = new Image();
  img.decoding = "async";
  let done = false;
  const finish = (v) => { if (!done) { done = true; cb(v); } };
  img.onload = () => {
    try {
      const W = 48, H = 48;
      const c = document.createElement("canvas");
      c.width = W; c.height = H;
      const ctx = c.getContext("2d", { willReadFrequently: true });
      ctx.drawImage(img, 0, 0, W, H);
      const data = ctx.getImageData(0, 0, W, H).data;
      // accumulate hue in 24 buckets, weighted by saturation * mid-lightness emphasis
      const buckets = new Array(24).fill(0);
      let vibrantWeight = 0;
      for (let i = 0; i < data.length; i += 4) {
        const a = data[i + 3];
        if (a < 200) continue;
        const [h, s, l] = rgb2hsl(data[i], data[i + 1], data[i + 2]);
        if (l < 0.12 || l > 0.92 || s < 0.18) continue;       // skip near-black/white/gray
        const w = s * (1 - Math.abs(l - 0.5));                 // favour saturated mid tones
        buckets[Math.floor(h / 15) % 24] += w;
        vibrantWeight += w;
      }
      if (vibrantWeight < 6) return finish(null);              // mostly grayscale → fallback
      let bi = 0;
      for (let i = 1; i < buckets.length; i++) if (buckets[i] > buckets[bi]) bi = i;
      // weighted hue centre across the winning bucket and its neighbours
      let num = 0, den = 0;
      for (let d = -1; d <= 1; d++) {
        const idx = (bi + d + 24) % 24;
        const hueCentre = idx * 15 + 7.5;
        num += hueCentre * buckets[idx];
        den += buckets[idx];
      }
      const hue = den ? Math.round(num / den) : bi * 15 + 7.5;
      finish(hue);
    } catch (e) { finish(null); }
  };
  img.onerror = () => finish(null);
  img.src = "/covers/" + slug + ".jpg";
  // safety timeout
  setTimeout(() => finish(null), 4000);
}

function AlbumAccent({ album }) {
  const ref = useXRef(null);
  useXEffect(() => {
    const root = ref.current && ref.current.closest(".album-page");
    if (!root) return;
    let cancelled = false;
    root.style.removeProperty("--accent");        // start from default vermillion
    extractCoverHue(album.slug, (hue) => {
      if (cancelled || hue == null) return;        // fallback: keep default
      // Fixed on-brand chroma/lightness; only the hue varies per album.
      // L 0.55 / C 0.13 clears AA contrast on the light paper for text use.
      root.style.setProperty("--accent", `oklch(0.55 0.13 ${hue})`);
    });
    return () => { cancelled = true; if (root) root.style.removeProperty("--accent"); };
  }, [album.slug]);
  return <span ref={ref} aria-hidden="true" style={{ display: "none" }} />;
}
window.AlbumAccent = AlbumAccent;

/* ---- six-dimension scoring card (mirrors the homepage worked example) ----
   No per-dimension data exists in the dataset, so #1 (What's Going On) uses the
   authored breakdown from the homepage (composite 9.90) and every other album
   derives a deterministic, on-brand spread around its rank-based composite
   (the same 9.9 − (rank−1)×0.07 scheme the homepage top-ten uses). */
function bbrHashStr(s) { let x = 0; for (let i = 0; i < s.length; i++) x = (x * 131 + s.charCodeAt(i)) >>> 0; return x; }
const BBR_SCORE_DIMS = [
  ["songwriting", "Songwriting"], ["production", "Production"], ["performance", "Performance"],
  ["innovation", "Innovation"], ["influence", "Influence"], ["cohesion", "Cohesion"],
];
function bbrScoreFor(album) {
  if (album.slug === "whats-going-on") {
    return {
      composite: 9.9,
      dims: [
        { key: "songwriting", label: "Songwriting", value: 9.7 },
        { key: "production", label: "Production", value: 9.9 },
        { key: "performance", label: "Performance", value: 9.6 },
        { key: "innovation", label: "Innovation", value: 9.8 },
        { key: "influence", label: "Influence", value: 10.0 },
        { key: "cohesion", label: "Cohesion", value: 9.9 },
      ],
    };
  }
  const composite = Math.max(2.6, Math.round((9.9 - (album.rank - 1) * 0.07) * 100) / 100);
  const dims = BBR_SCORE_DIMS.map(([key, label]) => {
    const r = bbrHashStr(album.slug + ":" + key);
    const off = ((r % 1000) / 1000 - 0.44) * 1.8; // ≈ −0.79 … +1.0, distinctive per album/dim
    let v = Math.max(1.2, Math.min(10, composite + off));
    return { key, label, value: Math.round(v * 10) / 10 };
  });
  return { composite, dims };
}
window.bbrScoreFor = bbrScoreFor;

function ScoreBreakdown({ album, onHowToScore }) {
  const { composite, dims } = bbrScoreFor(album);
  return (
    <div className="scorebreak" aria-label="Scoring breakdown">
      <div className="sb-head">
        <span className="sb-eyebrow">The scorecard</span>
        {onHowToScore && <button className="sb-how" onClick={onHowToScore}>How we score →</button>}
      </div>
      <div className="sb-rows">
        {dims.map((d) => (
          <div className="sb-row" key={d.key}>
            <span className="sb-dim">{d.label}</span>
            <span className="sb-meter" aria-hidden="true"><span className="sb-fill" style={{ width: (d.value * 10) + "%" }} /></span>
            <span className="sb-score">{d.value.toFixed(1)}</span>
          </div>
        ))}
      </div>
      <div className="sb-composite">
        <span className="sb-comp-num">{composite.toFixed(2)}</span>
        <span className="sb-comp-side">
          <span className="sb-comp-lbl">Composite</span>
          <span className="sb-comp-note">Songwriting &amp; production weighted ×1.25 · recalculated quarterly</span>
        </span>
      </div>
    </div>
  );
}
window.ScoreBreakdown = ScoreBreakdown;
