/* ===========================================================
   DossierValues — live market values for the pressing table on an album or
   deep-dive page, with a DISCREET fallback.

   WHY
   Until now a dossier showed hand-written editorial figures while the collection
   pages showed live, condition-matched values from lib/valuation.js. The same
   record could therefore carry two different numbers depending on which page a
   reader was on, and the deep dives were worse again: several pressings shipped
   `values: {}` and rendered an empty strip.

   This module puts the dossier on the collection's valuation path. It calls
   /api/discogs-price?action=dossier-values&slug=… which resolves each pressing
   row to its own Discogs release (by catalogue number) and prices all three
   displayed grades through the same valuate() the collection uses.

   THE CONDITION LADDER — the important part. Per grade, independently:
     1. Discogs price guide for THIS pressing at THIS condition   -> show it
     2. eBay asks, or the guide for the album rather than the row  -> show it
     3. our own estimate (cheapest listing x condition multiplier) -> show it, said so
     4. the researched editorial figure                            -> show it, unmarked
     5. none of the above                                          -> show nothing

   Each grade walks the ladder ON ITS OWN, so a pressing with a guide price for VG+
   and nothing for Mint shows VG+ and omits Mint rather than inventing one. That is
   what "gracefully" means here: the strip shrinks, it never guesses.

   Step 5 is deliberate and is what "discreet" means. A missing value must never
   render as "N/A", "£0", an error, or a spinner that never resolves. Nothing on the
   page moves when the fetch fails, because the editorial figures are rendered first
   and only replaced on success.

   AND WE SAY WHICH RUNG IT CAME FROM. Every live figure carries a `basis` from
   valuate(): a price guide reading, an eBay reading, or our own model. They are not
   the same claim and must not look the same. Rungs 1-2 are market readings; rung 3
   is a model and is labelled as an estimate, because a reader deciding what to pay
   deserves to know which they are looking at.
   =========================================================== */

/* One in-flight request per slug per page load, plus a short sessionStorage
   cache so moving between pressings (or back to a page) does not refetch. The
   endpoint is CDN-cached for a week; this just avoids duplicate calls in-tab. */
const _dvCache = new Map();      // slug -> resolved payload
const _dvInflight = new Map();   // slug -> Promise
const DV_TTL_MS = 60 * 60 * 1000;

function dvSessionGet(slug) {
  try {
    const raw = sessionStorage.getItem("bbr:dv:" + slug);
    if (!raw) return null;
    const j = JSON.parse(raw);
    if (!j || !j.t || Date.now() - j.t > DV_TTL_MS) return null;
    return j.d;
  } catch { return null; }
}
function dvSessionSet(slug, data) {
  try { sessionStorage.setItem("bbr:dv:" + slug, JSON.stringify({ t: Date.now(), d: data })); }
  catch { /* private mode / quota — the in-memory cache still helps */ }
}

function fetchDossierValues(slug) {
  if (_dvCache.has(slug)) return Promise.resolve(_dvCache.get(slug));
  const cached = dvSessionGet(slug);
  if (cached) { _dvCache.set(slug, cached); return Promise.resolve(cached); }
  if (_dvInflight.has(slug)) return _dvInflight.get(slug);

  const p = fetch("/api/discogs-price?action=dossier-values&slug=" + encodeURIComponent(slug))
    .then((r) => (r.ok ? r.json() : null))
    .then((j) => {
      // Index by pressing id so the renderer does not care about array order.
      const byId = {};
      if (j && Array.isArray(j.pressings)) {
        for (const row of j.pressings) if (row && row.id) byId[row.id] = row;
      }
      const out = { ok: !!(j && j.priced), byId, meta: j || null };
      _dvCache.set(slug, out);
      dvSessionSet(slug, out);
      return out;
    })
    .catch(() => {
      // A failed fetch is indistinguishable from "no data" by design: the
      // editorial figures stay on screen and nothing is said about it.
      const out = { ok: false, byId: {}, meta: null };
      _dvCache.set(slug, out);
      return out;
    })
    .finally(() => _dvInflight.delete(slug));

  _dvInflight.set(slug, p);
  return p;
}

/* Hook: returns { live, ok } where live is { [pressingId]: row }.
   Starts empty, so the first paint always shows the editorial figures. */
function useDossierValues(slug) {
  const [state, setState] = React.useState(() => {
    const seeded = slug && _dvCache.get(slug);
    return seeded || { ok: false, byId: {}, meta: null };
  });

  React.useEffect(() => {
    if (!slug) return;
    let alive = true;
    fetchDossierValues(slug).then((d) => { if (alive) setState(d); });
    return () => { alive = false; };
  }, [slug]);

  return state;
}

/* Editorial figures are a mix of numbers (420) and prose
   ("£280–£420 (sealed mono: £1,800+)"). Render either faithfully: a number gets
   a £ and thousands separators, a string is already formatted copy. */
function formatEditorial(v) {
  if (v == null || v === "") return null;
  if (typeof v === "number") return isFinite(v) ? "£" + v.toLocaleString("en-GB") : null;
  const s = String(v).trim();
  return s || null;
}
function formatLive(n) {
  if (typeof n !== "number" || !isFinite(n)) return null;
  // Market values are shown to the pound; pennies imply a precision we do not have.
  return "£" + Math.round(n).toLocaleString("en-GB");
}

const GRADE_ORDER = ["mint", "vgplus", "vg"];
const GRADE_LABEL = { mint: "Mint", vgplus: "VG+", vg: "VG", sealed: "Sealed" };

/* PressingValues — the three-grade strip for ONE pressing row.
   `pressing` is the editorial row; `live` is the endpoint row for it (or null). */
function PressingValues({ pressing, live }) {
  const editorial = (pressing && pressing.values) || {};
  const liveVals = (live && live.values) || {};

  const cells = [];
  for (const g of GRADE_ORDER) {
    const cell = liveVals[g];
    const l = cell && cell.value != null ? formatLive(cell.value) : null;
    const e = formatEditorial(editorial[g]);
    // Ladder, walked per grade: live, else editorial, else omit the cell entirely.
    if (!l && !e) continue;
    cells.push({
      grade: g, text: l || e, isLive: !!l,
      basis: l && cell ? (cell.basis || null) : null,
      // A modelled figure is flagged on the cell itself, not just in the footnote,
      // because a reader scanning three numbers will not read a footnote.
      estimated: !!(l && cell && cell.basis === "marketplace_estimate"),
    });
  }
  // Sealed is editorial-only (Discogs has no sealed grade) and optional.
  const sealed = formatEditorial(editorial.sealed);
  if (sealed) cells.push({ grade: "sealed", text: sealed, isLive: false });

  // No live figure and no editorial one. Returning null renders a pressing row with
  // three condition columns and nothing under them, and a reader cannot tell whether
  // the record is worthless, whether we forgot, or whether something failed to load.
  // Thirteen rows across the deep dives are in this state: they were written with
  // `values: {}` on the assumption the live endpoint would always fill them, so when
  // it cannot resolve the release they silently show nothing at all.
  //
  // Say so instead. "Not established" is true of these pressings — an RL-cut premium
  // or a rarely-traded first state genuinely has no settled market price — and it is
  // far better than a blank that reads as a bug.
  if (!cells.length) {
    return (
      <div className="ld-values ld-values-none">
        <span className="ldv-none">Market price not established</span>
      </div>
    );
  }

  const anyLive = cells.some((c) => c.isLive);

  return (
    <div className="ld-values">
      {cells.map((c) => (
        <div className={"ldv" + (c.isLive ? " ldv-live" : "") + (c.estimated ? " ldv-est" : "")} key={c.grade}>
          <span className="ldv-k">{GRADE_LABEL[c.grade] || c.grade.toUpperCase()}</span>
          <span className="ldv-v">
            {c.text}
            {c.estimated && <abbr className="ldv-approx" title="Estimated from the cheapest copy listed, not a guide price">~</abbr>}
          </span>
        </div>
      ))}
      {anyLive && <ValueProvenance live={live} cells={cells} />}
    </div>
  );
}

/* What each basis means in plain English. "Guide price" rather than "market value"
   because a Discogs guide figure is what the site suggests a copy is worth, not a
   sale that happened — overstating it would be the kind of false precision this
   whole module exists to avoid. */
const BASIS_LABEL = {
  discogs_guide: "Discogs guide price for this pressing",
  discogs_guide_album: "Discogs guide price for the album, not this pressing",
  ebay_asks: "Based on current eBay asking prices",
  marketplace_estimate: "Estimated from the cheapest copy listed",
};

/* One quiet line saying where the live numbers came from. Only rendered when at
   least one cell IS live — we never advertise the fallback.

   When the grades came from different rungs, name the weakest: the strip is only as
   trustworthy as its softest figure, and claiming the best of them would be the
   flattering read rather than the honest one. */
const BASIS_RANK = ["discogs_guide", "ebay_asks", "discogs_guide_album", "marketplace_estimate"];

function ValueProvenance({ live, cells }) {
  if (!live) return null;
  const bases = (cells || [])
    .filter((c) => c.isLive && c.basis)
    .map((c) => c.basis);
  const weakest = BASIS_RANK.filter((b) => bases.includes(b)).pop() || null;
  const label = weakest
    ? BASIS_LABEL[weakest]
    // No basis at all means a sweep from before valuate() reported one.
    : (live.granularity === "pressing"
        ? "Live market value for this pressing"
        : "Live market value for this album");
  const conf = live.confidence || null;
  const corroborated = bases.includes("discogs_guide") && bases.includes("ebay_asks");
  return (
    <p className="ldv-provenance">
      <span className={"ldv-dot conf-" + (conf || "low")} aria-hidden="true" />
      {label}
      {corroborated ? " · Discogs and eBay agree" : ""}
      {conf ? " · " + conf + " confidence" : ""}
    </p>
  );
}

/* ValuesStrip — the headline Mint / VG+ / VG strip in the Collector's corner.
   Uses the best available live figure across pressings (the highest-confidence
   pressing-level hit) and otherwise the editorial copy, which on the deep dives
   is prose rather than a number and must render as written. */
function ValuesStrip({ collector, live }) {
  const editorial = (collector && collector.values) || {};
  const pressings = (collector && collector.pressings) || [];

  const cells = GRADE_ORDER.map((g) => {
    // Prefer the live value of the pressing the editorial calls the grail —
    // the first row — falling back to any priced row.
    let liveText = null, conf = null;
    for (const p of pressings) {
      const row = live && live.byId ? live.byId[p.id] : null;
      const cell = row && row.values && row.values[g];
      if (cell && cell.value != null) { liveText = formatLive(cell.value); conf = cell.confidence; break; }
    }
    const e = formatEditorial(editorial[g]);
    if (!liveText && !e) return null;
    return { grade: g, text: liveText || e, isLive: !!liveText, conf };
  }).filter(Boolean);

  if (!cells.length) return null;

  return (
    <div className="values-strip">
      {cells.map((c) => (
        <div key={c.grade} className={c.isLive ? "vs-live" : undefined}>
          <b>{GRADE_LABEL[c.grade]}</b>
          <span>{c.text}</span>
        </div>
      ))}
    </div>
  );
}

Object.assign(window, {
  useDossierValues,
  fetchDossierValues,
  PressingValues,
  ValuesStrip,
  formatEditorialValue: formatEditorial,
});
