<h1>Titles</h1>
<div class="pagebar"><span id="catcount"></span></div>
<div class="filterbar">
  <input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter titles">
  <button id="shakyfilter" aria-pressed="false" hidden
          title="reads the model wasn't sure of — confirm or fix each one">shaky reads</button>
  <button id="addtitle" aria-expanded="false"
          title="a game no photo shows — an expansion inside a base box, a game away from the shelves">add a game</button>
</div>
<form id="addform" class="editform card" hidden>
  <label>Title <input name="title" required></label>
  <label>Publisher <input name="publisher"></label>
  <label>Edition <input name="edition"></label>
  <label>Year <input name="year" inputmode="numeric" size="6"></label>
  <label>Language <input name="language"></label>
  <span class="editactions">
    <button type="submit" class="primary">add</button>
    <button type="button" id="addcancel">cancel</button>
  </span>
  <span class="edithint">joins the titles list like a photo read — resolve matches it
    next run. A whole box of expansions? <button type="button" id="topicks"
    class="linkish">search BGG and tick them off</button></span>
</form>
<div id="pickpanel" class="editform card" hidden>
  <label>Search BGG <input id="pickq" placeholder="red dragon inn" size="28"></label>
  <span class="editactions"><button type="button" id="dopicksearch" class="primary">search</button>
    <button type="button" id="pickcancel">cancel</button></span>
  <div id="pickresults"></div>
  <div id="pickfooter" hidden>
    <label>They all live inside
      <select id="pickcontainer"><option value="">their own boxes</option></select>
    </label>
    <button type="button" id="addpicks" class="primary">add checked</button>
    <span class="edithint">each checked result is added already-matched (you just
      picked it off BGG's list) — no resolve pass needed</span>
  </div>
</div>
<div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
<script>
"use strict";
let CATALOG = [];
let EDITING = null;  // lineKey of the row whose editor is open
let PICKS = [];      // last BGG search results shown in the pick panel

function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); }

function editorRow(c) {
  const cue = c.cues || {};
  return `
  <tr class="editrow"><td colspan="5">
    <form class="editform" data-title="${esc(c.title_raw)}"
          data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}">
      <label>Title <input name="title" value="${esc(c.title_raw)}" required></label>
      <label>Publisher <input name="publisher" value="${esc(cue.publisher || "")}"></label>
      <label>Edition <input name="edition" value="${esc(cue.edition || "")}"></label>
      <label>Year <input name="year" value="${esc(cue.year ?? "")}" inputmode="numeric" size="6"></label>
      <label>Language <input name="language" value="${esc(cue.language || "")}"></label>
      <span class="editactions">
        <button type="submit" class="primary">save</button>
        <button type="button" class="canceledit">cancel</button>
        ${c.bgg_id && ["auto", "approved"].includes(c.status)
          ? `<button type="button" class="wrongmatch danger">wrong match — re-search</button>`
          : ""}
        <button type="button" class="removetitle danger"
                title="not a game, a duplicate read of a copy already listed, or otherwise doesn't belong">
          remove this line</button>
      </span>
      <span class="edithint">saving re-queues this title for resolve with the corrected data</span>
    </form>
  </td></tr>`;
}

let SHAKY_ONLY = false;

function render() {
  const q = document.getElementById("catsearch").value.trim().toLowerCase();
  const shakyCount = CATALOG.filter(c => c.shaky).length;
  const fbtn = document.getElementById("shakyfilter");
  fbtn.hidden = shakyCount === 0 && !SHAKY_ONLY;
  fbtn.textContent = `shaky reads — ${shakyCount}`;
  fbtn.setAttribute("aria-pressed", String(SHAKY_ONLY));
  const sorted = [...CATALOG].sort((a, b) =>
    a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" }));
  const searched = q
    ? sorted.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
    : sorted;
  const rows = SHAKY_ONLY ? searched.filter(c => c.shaky) : searched;
  document.getElementById("catcount").innerHTML =
    `<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
  document.getElementById("catbody").innerHTML = rows.length
    ? `<div class="catalog"><table>` + rows.map(c => `
      <tr>
        <td class="t">${esc(c.title_raw)}
          ${c.split_copy ? `<span class="chip merged">copy</span>` : ""}
          ${c.shaky ? `<span class="chip shaky"
              title="the model wasn't sure of this read — press ✓ if it's right, or edit it">shaky read</span>` : ""}</td>
        <td>${statusChip(c)}</td>
        <td class="meta">${metaLine(c)}</td>
        <td class="meta">${c.photos.length
          ? c.photos.map(p =>
              `<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
            ).join(", ")
          : `<span class="chip merged">added by hand</span>`}</td>
        <td class="actions">${c.can_split
          ? `<button class="split" data-title="${esc(c.title_raw)}"
                     data-photos="${esc(c.photos.join(";"))}"
                     data-rowix="${c.row_ix ?? ""}"
                     title="one line, several boxes? make each photo its own copy">
               split into copies</button>`
          : ""}
          ${c.status === "local"
            ? `<button class="wrongmatch" data-title="${esc(c.title_raw)}"
                     data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}"
                     title="search BGG and RPGGeek again — RPGs and expansions often are listed">
                 look it up</button>`
            : ""}
          ${c.version_status === "version_ambiguous"
            ? `<a class="ballotlink" href="/review#editions"
                  title="this copy's edition ballot is open">ballot in Review →</a>`
            : ""}
          ${c.bgg_id && ["auto", "approved"].includes(c.status)
              && c.version_status !== "version_ambiguous"
            ? ["version_auto", "version_approved"].includes(c.version_status)
              ? `<button class="pickedition" data-title="${esc(c.title_raw)}"
                       data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}"
                       title="wrong printing? reopen the full edition list — the current pick stays until you choose">
                   change edition</button>`
              : `<button class="pickedition" data-title="${esc(c.title_raw)}"
                       data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}"
                       title="you know which printing this box is — fetch the full edition list into Review">
                   pick edition</button>`
            : ""}
          ${c.shaky ? `<button class="confirmread" data-title="${esc(c.title_raw)}"
                  data-photos="${esc(c.photos.join(";"))}"
                  title="the read is correct as-is — mark it verified">✓ looks right</button>` : ""}
          <button class="edit" data-key="${esc(lineKey(c))}"
                  title="fix a misread title or add cues you already know">edit</button>
        </td>
      </tr>` + (EDITING === lineKey(c) ? editorRow(c) : "")).join("") + `</table></div>`
    : `<p class="empty">${CATALOG.length
        ? "No titles match that filter."
        : `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
}

const GATE = changeGate();
async function refresh() {
  const state = await fetchJSON("/api/state");
  if (EDITING) return;  // never repaint under an open editor
  GATE(state.catalog, () => { CATALOG = state.catalog; render(); });
}

document.getElementById("catbody").addEventListener("click", async e => {
  const cancel = e.target.closest("button.canceledit");
  if (cancel) { EDITING = null; GATE.reset(); render(); refresh().catch(() => {}); return; }
  const wm = e.target.closest("button.wrongmatch");
  if (wm) {
    // in the edit panel it means "wrong match"; on a local row it means
    // "look it up" — same reopen, different starting point
    const f = wm.closest("form.editform");
    const src = f ? f.dataset : wm.dataset;
    if (f && !confirm(`"${src.title}" matched the wrong game? This clears the ` +
                      `match and sends it back to Review for a re-search.`)) return;
    const res = await apiPost("/api/reopen-match", {
      title_raw: src.title,
      source_photos: src.photos,
      row_ix: src.rowix === "" ? null : Number(src.rowix),
    });
    if (res) {
      EDITING = null; GATE.reset(); refresh().catch(() => {});
      showToast(`searching BGG and RPGGeek for <b>${esc(src.title)}</b> —
        <a href="/review#matches">pick a match in Review</a>`);
    }
    return;
  }
  const pe = e.target.closest("button.pickedition");
  if (pe) {
    const res = await apiPost("/api/open-versions", {
      title_raw: pe.dataset.title,
      source_photos: pe.dataset.photos,
      row_ix: pe.dataset.rowix === "" ? null : Number(pe.dataset.rowix),
    });
    if (res) {
      showToast(`edition ballot ready for <b>${esc(pe.dataset.title)}</b> —
        <a href="/review#editions">pick it in Review</a>`);
      GATE.reset(); refresh().catch(() => {});
    }
    return;
  }
  const ok = e.target.closest("button.confirmread");
  if (ok) {
    const res = await apiPost("/api/edit-title", {
      title_raw: ok.dataset.title,
      source_photos: ok.dataset.photos,
      confirm: true,
    });
    if (res) { GATE.reset(); refresh().catch(() => {}); }
    return;
  }
  const rm = e.target.closest("button.removetitle");
  if (rm) {
    const f = rm.closest("form.editform");
    if (!confirm(`Remove "${f.dataset.title}" from the titles list? ` +
                 `Right for a line that shouldn't exist: not a game, or a duplicate ` +
                 `read of a copy that's already its own line. ` +
                 `(One line for several REAL boxes? Use split instead.) ` +
                 `Re-running extract won't bring it back — the removal is saved ` +
                 `in data/title_removals.json (delete its record there to undo).`)) return;
    const res = await apiPost("/api/remove-title", {
      title_raw: f.dataset.title,
      source_photos: f.dataset.photos,
    });
    if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); }
    return;
  }
  const edit = e.target.closest("button.edit");
  if (edit) {
    EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key;
    render();
    return;
  }
  const b = e.target.closest("button.split");
  if (!b) return;
  const n = b.dataset.photos.split(";").length;
  if (!confirm(`Split "${b.dataset.title}" into ${n} separate copies (one per photo)? ` +
               `Each picks its own edition afterward.`)) return;
  const res = await apiPost("/api/split", {
    title_raw: b.dataset.title,
    source_photos: b.dataset.photos,
    row_ix: b.dataset.rowix === "" ? null : Number(b.dataset.rowix),
  });
  if (res) refresh().catch(() => {});
});

document.getElementById("catbody").addEventListener("submit", async e => {
  const f = e.target.closest("form.editform");
  if (!f) return;
  e.preventDefault();
  const orig = CATALOG.find(c => lineKey(c) === EDITING) || {};
  const cue = orig.cues || {};
  const v = name => f.elements[name].value;
  const body = { title_raw: f.dataset.title, source_photos: f.dataset.photos };
  if (v("title").trim() !== f.dataset.title) body.title_new = v("title").trim();
  if (v("publisher") !== (cue.publisher || "")) body.publisher = v("publisher");
  if (v("edition") !== (cue.edition || "")) body.edition = v("edition");
  if (v("year") !== String(cue.year ?? "")) body.year = v("year");
  if (v("language") !== (cue.language || "")) body.language = v("language");
  if (Object.keys(body).length <= 2) {
    // saving unchanged: on a shaky line that means "it's right as-is"
    if (orig.shaky) {
      const res = await apiPost("/api/edit-title", { ...body, confirm: true });
      if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); return; }
    }
    EDITING = null; render(); return;
  }
  const res = await apiPost("/api/edit-title", body);
  if (res) {
    EDITING = null; GATE.reset(); refresh().catch(() => {});
    // an edit re-queues any matched row: without a resolve run the line
    // sits "awaiting resolve" — say so, and offer the run right here
    const toast = showToast(`saved — a previously matched title re-matches
      on the next resolve run. <button id="resolvenow">run resolve now</button>`);
    const btn = toast.querySelector("#resolvenow");
    if (btn) btn.addEventListener("click", async () => {
      const r = await apiPost("/api/run/resolve");
      if (r) showToast(`resolve running — progress on the
        <a href="/">Pipeline</a> page; this list updates itself`);
    });
  }
});

const addForm = document.getElementById("addform");
const addBtn = document.getElementById("addtitle");
addBtn.addEventListener("click", () => {
  addForm.hidden = !addForm.hidden;
  addBtn.setAttribute("aria-expanded", String(!addForm.hidden));
  if (!addForm.hidden) addForm.elements.title.focus();
});
document.getElementById("addcancel").addEventListener("click", () => {
  addForm.hidden = true;
  addBtn.setAttribute("aria-expanded", "false");
  addForm.reset();
});

// -- the pick panel: one BGG search, tick off a whole box ---------------
const pickPanel = document.getElementById("pickpanel");
document.getElementById("topicks").addEventListener("click", () => {
  addForm.hidden = true;
  pickPanel.hidden = false;
  document.getElementById("pickq").focus();
});
document.getElementById("pickcancel").addEventListener("click", () => {
  pickPanel.hidden = true;
  document.getElementById("pickresults").innerHTML = "";
  document.getElementById("pickfooter").hidden = true;
});
async function pickSearch() {
  const q = document.getElementById("pickq").value.trim();
  if (!q) return;
  const box = document.getElementById("pickresults");
  box.innerHTML = `<p class="meta">searching BGG…</p>`;
  let data;
  try {
    data = await fetchJSON(`/api/bgg-search?q=${encodeURIComponent(q)}`);
  } catch (err) {
    box.innerHTML = `<p class="meta">search failed: ${esc(err.message || err)}</p>`;
    return;
  }
  PICKS = data.results;
  box.innerHTML = PICKS.length
    ? PICKS.map((r, i) => `
      <label class="pickrow">
        <input type="checkbox" data-pick-ix="${i}" ${r.already ? "disabled" : ""}>
        ${esc(r.name)} <span class="meta">${esc(r.year ?? "—")} · ${esc(r.type)}</span>
        ${r.already ? `<span class="chip ok">already cataloged</span>` : ""}
      </label>`).join("")
    : `<p class="meta">BGG found nothing for that.</p>`;
  document.getElementById("pickfooter").hidden = !PICKS.length;
  // container choices: anything already matched, the searched family first
  const owned = CATALOG.filter(c => c.bgg_id && ["auto", "approved"].includes(c.status));
  const ql = q.toLowerCase();
  owned.sort((a, b) =>
    (b.bgg_name || "").toLowerCase().includes(ql) -
    (a.bgg_name || "").toLowerCase().includes(ql));
  document.getElementById("pickcontainer").innerHTML =
    `<option value="">their own boxes</option>` + owned.map(c =>
      `<option value="${esc(c.bgg_id)}">${esc(c.bgg_name)}</option>`).join("");
}
document.getElementById("dopicksearch").addEventListener("click", pickSearch);
document.getElementById("pickq").addEventListener("keydown", e => {
  if (e.key === "Enter") { e.preventDefault(); pickSearch(); }
});
document.getElementById("addpicks").addEventListener("click", async () => {
  const picks = Array.from(
    document.querySelectorAll("#pickresults input:checked")
  ).map(cb => PICKS[Number(cb.dataset.pickIx)]);
  if (!picks.length) return;
  const res = await apiPost("/api/add-picked", {
    picks, stored_in: document.getElementById("pickcontainer").value,
  });
  if (res) {
    // apiPost returns the raw Response; the counts live in its body
    const data = await res.json().catch(() => ({added: picks.length, skipped: []}));
    showToast(`added <b>${data.added}</b> game(s)` +
      (data.skipped.length ? ` — ${data.skipped.length} skipped (already on file)` : ""));
    pickPanel.hidden = true;
    document.getElementById("pickresults").innerHTML = "";
    document.getElementById("pickfooter").hidden = true;
    GATE.reset();
    refresh().catch(() => {});
  }
});
addForm.addEventListener("submit", async e => {
  e.preventDefault();
  const v = name => addForm.elements[name].value;
  const res = await apiPost("/api/add-title", {
    title: v("title"), publisher: v("publisher"), edition: v("edition"),
    year: v("year"), language: v("language"),
  });
  if (res) {
    addForm.reset();
    addForm.hidden = true;
    addBtn.setAttribute("aria-expanded", "false");
    GATE.reset();
    refresh().catch(() => {});
  }
});

document.getElementById("catsearch").addEventListener("input", render);
document.getElementById("shakyfilter").addEventListener("click", () => {
  SHAKY_ONLY = !SHAKY_ONLY;
  render();
});
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000, () => showBanner(""));
</script>
