<h1>Review</h1>
<div class="pagebar">
  <span id="sectionlinks"></span>
  <span class="keyhelp">
    <kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd>–<kbd>9</kbd> pick ·
    <kbd>r</kbd> reject · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown ·
    <kbd>v</kbd> veto merge
  </span>
</div>
<div id="cards"></div>
<script>
"use strict";
let STATE = null;
let active = 0;

async function refresh() {
  const res = await fetch("/api/state");
  if (!res.ok) throw new Error(`${res.status}`);
  STATE = await res.json();
  render();
}

async function post(url, body) {
  let res;
  try {
    res = await fetch(url, {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify(body),
    });
  } catch (err) {
    alert("That didn't save (no response from the server): " + err);
    return;
  }
  if (!res.ok) {
    const detail = await res.json().then(d => d.detail).catch(() => null);
    alert("That didn't save: " + (detail ?? res.statusText));
    return;
  }
  try {
    STATE = await res.json();
    render();
  } catch (err) {
    // the decision saved server-side; only the re-render failed
    errorBanner(`saved, but the page failed to refresh (${err.message || err}) — reload the page`);
  }
}

function cueChips(cues) {
  const parts = [];
  if (cues.publisher) parts.push(`<span class="cue"><b>publisher</b> ${esc(cues.publisher)}</span>`);
  if (cues.edition) parts.push(`<span class="cue"><b>edition</b> ${esc(cues.edition)}</span>`);
  if (cues.year) parts.push(`<span class="cue"><b>year</b> ${esc(cues.year)}</span>`);
  if (cues.language) parts.push(`<span class="cue"><b>language</b> ${esc(cues.language)}</span>`);
  if (cues.art_notes) parts.push(`<span class="cue"><b>art</b> ${esc(cues.art_notes)}</span>`);
  return parts.length ? `<div class="cues">${parts.join("")}</div>` : "";
}

function shots(photos) {
  if (!photos.length) return `<div class="shots"><div class="noshot">photo not on disk</div></div>`;
  return `<div class="shots">` + photos.map(p =>
    `<a href="/photos/${encodeURIComponent(p)}" target="_blank" tabindex="-1">
       <img src="/photos/${encodeURIComponent(p)}" alt="source photo ${esc(p)}"></a>`
  ).join("") + `</div>`;
}

function thumbHtml(c) {
  if (c.thumbnail) return `<img class="thumb" src="${esc(c.thumbnail)}" alt="">`;
  const initial = (c.name || "?").trim().charAt(0).toUpperCase();
  return `<div class="thumb ph" aria-hidden="true">${esc(initial)}</div>`;
}

function matchCard(row, idx) {
  const cands = row.candidates.map((c, i) => `
    <li data-pick="${c.bgg_id}" title="press ${i + 1}" tabindex="0" role="button"
        aria-label="pick ${esc(c.name)} (${esc(c.year ?? "year unknown")})">
      <kbd>${i + 1}</kbd> ${thumbHtml(c)}
      <span><span class="cname">${esc(c.name)}</span>
        <span class="cmeta">${esc(c.year ?? "—")} · ${esc(c.type || "?")}
          · rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}</span></span>
    </li>`).join("");
  return `
  <section class="card actionable" data-kind="match" data-rowix="${row.row_ix}"
           data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
    ${shots(row.photos)}
    <div class="body">
      <p class="title">${esc(row.title_raw)}</p>
      <p class="status">${esc(row.match_status)} · ${idx + 1} of ${STATE.pending.length + STATE.versions.length} to review</p>
      ${cueChips(row.cues)}
      <ol class="cands">${cands || "<li class='cmeta'>no candidates — enter a BGG id or reject</li>"}</ol>
      <div class="rowactions">
        <span><kbd>m</kbd> <input type="text" inputmode="numeric" placeholder="BGG id, then ⏎"
              aria-label="manual BGG id"></span>
        <button class="reject" title="press r"><kbd>r</kbd> reject — not a game / bad read</button>
      </div>
    </div>
  </section>`;
}

function versionCard(row, idx) {
  const cands = row.candidates.map((v, i) => `
    <li data-pickver="${v.version_id}" title="press ${i + 1}" tabindex="0" role="button"
        aria-label="pick ${esc(v.name)} (${esc(v.year ?? "year unknown")})">
      <kbd>${i + 1}</kbd>
      <span><span class="cname">${esc(v.name)}</span>
        <span class="cmeta">${esc(v.year ?? "—")} · ${esc((v.publishers || []).join(", "))}
          · ${esc((v.languages || []).join(", "))} · score ${esc(v.score ?? "—")}</span></span>
    </li>`).join("");
  return `
  <section class="card actionable" data-kind="version" data-rowix="${row.row_ix}"
           data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
    <div class="body">
      <p class="title">${esc(row.bgg_name || row.title_raw)}</p>
      <p class="status">which edition? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p>
      <ol class="cands">${cands}</ol>
      <div class="rowactions">
        <button class="unknown" title="press u"><kbd>u</kbd> can't tell — leave version unset</button>
      </div>
    </div>
  </section>`;
}

function render() {
  const m = document.getElementById("cards");
  const s = STATE;
  showBanner((s.warnings || []).length
    ? `<div class="banner warn">${s.warnings.map(esc).join("<br>")}</div>` : "");

  const link = (n, label, anchor) => n ? `<a href="#${anchor}"><b>${n}</b> ${label}</a>` : "";
  document.getElementById("sectionlinks").innerHTML = [
    link(s.pending.length, "matches", "matches"),
    link(s.versions.length, "editions", "editions"),
    link(s.merges.length, "merged", "merges"),
    s.summary.unresolved ? `<span><b>${s.summary.unresolved}</b> awaiting resolve</span>` : "",
    `<span>${s.decisions} decided this sitting</span>`,
  ].filter(Boolean).join(" ");

  let html = "";
  if (!s.pending.length && !s.versions.length) {
    const waiting = s.summary.unresolved;
    html += `
    <div class="done">
      <h2>${waiting
        ? "Resolved set fully reviewed"
        : "All reviewed — every title is diff-ready"}</h2>
      <div class="nums">
        <div>${s.summary.extracted}<span>extracted</span></div>
        <div>${s.summary.recognized}<span>recognized</span></div>
        <div>${s.summary.version_updates}<span>with versions</span></div>
        <div>${s.summary.rejected}<span>rejected</span></div>
      </div>
      ${waiting
        ? `<p class="waiting">${waiting} titles are extracted but not yet matched
             to BGG — they're waiting on the API token.</p>
           <p class="next">When it arrives, run <b>resolve</b> from the <a href="/">pipeline</a>.</p>`
        : `<p class="next">Next: run <b>diff</b> from the <a href="/">pipeline</a>, then check the <a href="/queue">queue</a>.</p>`}
    </div>`;
  }
  if (s.pending.length) {
    html += `<h2 id="matches">Matches <span class="count">— pick the game each photo shows</span></h2>`;
    html += s.pending.map(matchCard).join("");
  }
  if (s.versions.length) {
    html += `<h2 id="editions">Editions <span class="count">— optional pass, never blocks uploads</span></h2>`;
    html += s.versions.map(versionCard).join("");
  }
  if (s.merges.length) {
    html += `<h2 id="merges">Merges <span class="count">— duplicate reads folded into one game; veto if wrong</span></h2>`;
    html += s.merges.map(mg => `
      <section class="card merge actionable" data-kind="merge"
               data-rowix="${mg.row_ix}"
               data-title="${esc(mg.title_raw)}" data-photos="${esc(mg.source_photos)}">
        <div class="body">
          <span class="cname">${esc(mg.title_raw)}</span>
          <span class="arrow">merged into</span>
          <span class="cname">${esc(mg.merged_into)}</span>
          <span class="cmeta">${esc(mg.bgg_name)} · ${esc(mg.bgg_id)}</span>
          <button class="veto" title="press v"><kbd>v</kbd> veto — these are different games</button>
        </div>
      </section>`).join("");
  }
  m.innerHTML = html;

  const cards = actionables();
  if (active >= cards.length) active = Math.max(0, cards.length - 1);
  highlight();

  m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => {
    const card = li.closest(".card");
    decide(card, "pick", Number(li.dataset.pick));
  });
  m.querySelectorAll("[data-pickver]").forEach(li => li.onclick = () => {
    const card = li.closest(".card");
    version(card, "pick", Number(li.dataset.pickver));
  });
  m.querySelectorAll(".reject").forEach(b => b.onclick = () =>
    decide(b.closest(".card"), "reject"));
  m.querySelectorAll(".unknown").forEach(b => b.onclick = () =>
    version(b.closest(".card"), "unknown"));
  m.querySelectorAll(".veto").forEach(b => b.onclick = () =>
    vetoMerge(b.closest(".card")));
  m.querySelectorAll(".rowactions input").forEach(inp => {
    inp.onkeydown = e => {
      if (e.key === "Enter" && inp.value.trim().match(/^\d+$/)) {
        decide(inp.closest(".card"), "manual", Number(inp.value.trim()));
      }
      if (e.key === "Escape") inp.blur();
      e.stopPropagation();
    };
  });
}

const actionables = () => [...document.querySelectorAll(".actionable")];

function highlight() {
  actionables().forEach((el, i) => el.classList.toggle("active", i === active));
  const el = actionables()[active];
  if (el) el.scrollIntoView({block: "nearest", behavior: "auto"});
}

const rowIx = card => card.dataset.rowix === undefined ? null : Number(card.dataset.rowix);
const decide = (card, action, bgg_id = null) => post("/api/decision", {
  title_raw: card.dataset.title, source_photos: card.dataset.photos,
  row_ix: rowIx(card), action, bgg_id,
});
const version = (card, action, version_id = null) => post("/api/version", {
  title_raw: card.dataset.title, source_photos: card.dataset.photos,
  row_ix: rowIx(card), action, version_id,
});
const vetoMerge = card => post("/api/veto-merge", {
  title_raw: card.dataset.title, source_photos: card.dataset.photos,
  row_ix: rowIx(card),
});

document.addEventListener("keydown", e => {
  if ((e.key === "Enter" || e.key === " ") && e.target.matches("[data-pick],[data-pickver]")) {
    e.preventDefault();
    e.target.click();
    return;
  }
  if (e.target.tagName === "INPUT") return;
  const cards = actionables();
  if (!cards.length) return;
  const card = cards[active];
  const kind = card?.dataset.kind;
  if (e.key === "j" || e.key === "ArrowDown") { active = Math.min(active + 1, cards.length - 1); highlight(); }
  else if (e.key === "k" || e.key === "ArrowUp") { active = Math.max(active - 1, 0); highlight(); }
  else if (/^[1-9]$/.test(e.key) && kind === "match") {
    const li = card.querySelectorAll("[data-pick]")[Number(e.key) - 1];
    if (li) decide(card, "pick", Number(li.dataset.pick));
  }
  else if (/^[1-9]$/.test(e.key) && kind === "version") {
    const li = card.querySelectorAll("[data-pickver]")[Number(e.key) - 1];
    if (li) version(card, "pick", Number(li.dataset.pickver));
  }
  else if (e.key === "r" && kind === "match") decide(card, "reject");
  else if (e.key === "u" && kind === "version") version(card, "unknown");
  else if (e.key === "v" && kind === "merge") vetoMerge(card);
  else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); }
});

refresh().catch(err => errorBanner(err.message || err));

// Live-follow the data files; only re-render on an actual change (keeps
// the keyboard cursor stable) and never mid-typing. Stale poll responses
// (answered before a decision landed) are discarded by revision.
let lastGood = null;
pollLoop(async () => {
  const fresh = await fetchJSON("/api/state");
  // discard stale responses — but only within one server lifetime: a
  // restart resets revision to 0 (boot changes), and refusing forever
  // would freeze the page
  if (STATE && fresh.boot === STATE.boot && fresh.revision < STATE.revision) return;
  if (JSON.stringify(fresh) !== JSON.stringify(STATE)) {
    STATE = fresh;
    render();
  }
}, 3000, () => render());
</script>
