/* Shared client plumbing for every bggpipe page: escaping, banners,
 * fetch helpers, and the sidebar's live count badges. Loaded as a
 * blocking script before each page's own script. */
"use strict";

const esc = s => String(s ?? "").replace(/[&<>"']/g,
  c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));

/* Contract for every innerHTML sink in this app: interpolated values MUST
 * pass through esc() at the call site. The API serves only local pipeline
 * data, but photo names and BGG titles still count as untrusted. */
function showBanner(html) {
  document.getElementById("banner").innerHTML = html;
}

function errorBanner(detail) {
  // an HTTP status means the server answered — the problem is its data
  const httpish = /^\d{3}$/.test(String(detail));
  showBanner(`<div class="banner error">${httpish
    ? `the server hit an error (HTTP ${esc(detail)}) — a data file may be broken`
    : `lost contact with the server (${esc(detail)})`} — check its terminal</div>`);
}

async function fetchJSON(url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`${res.status}`);
  return res.json();
}

async function apiPost(url, body) {
  let res;
  try {
    res = await fetch(url, {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify(body ?? {}),
    });
  } catch (err) {
    alert("No response from the server: " + err);
    return null;
  }
  if (!res.ok) {
    const detail = await res.json().then(d => d.detail).catch(() => null);
    alert("That didn't work: " + (detail ?? res.statusText));
    return null;
  }
  return res;
}

/* Per-page change gate for poll loops: render only when the payload
 * changed, and record it only after render returns — a throw leaves the
 * page stale, so the next poll retries instead of freezing on "current". */
function changeGate() {
  let seen = null;
  const gate = (value, render) => {
    const key = JSON.stringify(value);
    if (key === seen) return;
    render();
    seen = key;
  };
  gate.reset = () => { seen = null; };
  return gate;
}

/* Poll `fn` every `ms`; after 3 consecutive failures show the lost-contact
 * banner, and clear it (via `recovered`) on the next success. */
function pollLoop(fn, ms, recovered) {
  let misses = 0;
  setInterval(async () => {
    const el = document.activeElement;
    if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return;
    try {
      await fn();
      if (misses >= 3 && recovered) recovered();
      misses = 0;
    } catch (err) {
      if (++misses >= 3) errorBanner(err.message || err);
    }
  }, ms);
}

/* Mobile: the top bar's hamburger opens the nav; navigation reloads the
 * page, so each page starts with the menu closed. */
{
  const toggle = document.querySelector(".navtoggle");
  if (toggle) toggle.addEventListener("click", () => {
    const open = document.body.classList.toggle("navopen");
    toggle.setAttribute("aria-expanded", String(open));
  });
}

/* Sidebar badges: the counts that mean "something wants your attention". */
async function refreshBadges() {
  const p = await fetchJSON("/api/pipeline");
  const set = (name, n) => {
    const el = document.querySelector(`[data-badge="${name}"]`);
    if (el) el.textContent = n > 0 ? String(n) : "";
  };
  set("photos", p.reshoot);
  set("titles", p.shaky_reads);
  set("review", p.pending_review);
  set("queue", p.to_add + p.to_update);
  return p;
}
refreshBadges().catch(() => {});
setInterval(() => refreshBadges().catch(() => {}), 5000);

/* One-line match summary for a titles/photo table row. Empty when the
 * row has no BGG data yet, so the mobile stacker can hide the cell. */
function metaLine(c) {
  return [
    c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : "",
    c.version_name ? esc(c.version_name) : "",
    c.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : "",
  ].filter(Boolean).join(" · ");
}

/* Reshoot work-order card, shared by the photos gallery and the photo
 * detail page (which drops the thumbnail and source line — the photo is
 * right there). Dismiss buttons are wired by wireDismiss below. */
function ticketCard(s, {showPhoto = true} = {}) {
  const img = showPhoto && s.photo_exists
    ? `<a href="/photos/${encodeURIComponent(s.photo)}" target="_blank" tabindex="-1">
         <img src="/photos/${encodeURIComponent(s.photo)}" alt="photo ${esc(s.photo)}"></a>`
    : "";
  return `
  <section class="ticket"
           data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
           data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
    <span class="stencil">reshoot</span>
    ${img}
    <div>
      <div class="loc">${esc(s.location) || "somewhere in " + esc(s.photo)}</div>
      ${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
      ${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
      ${showPhoto ? `<div class="notes">from ${esc(s.photo)} — take a closer shot and drop it above</div>` : ""}
      <button class="dismiss">dismiss — found it / not a game</button>
    </div>
  </section>`;
}

function wireDismiss(root, done) {
  root.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => {
    const t = b.closest(".ticket");
    const res = await apiPost("/api/dismiss", {
      photo: t.dataset.photo, location: t.dataset.location,
      partial_text: t.dataset.partial, art_notes: t.dataset.art,
    });
    if (res) done();
  }));
}

/* Status chip for a catalog entry — shared by the catalog and photo pages. */
function statusChip(c) {
  if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`;
  if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${esc(c.status)}</span>`;
  if (c.status === "rejected") return `<span class="chip no">rejected</span>`;
  if (c.status === "local") return `<span class="chip open">local — not on BGG</span>`;
  if (c.status === "merged") return `<span class="chip merged" title="merged into ${esc(c.merged_into)}">merged → ${esc(c.merged_into)}</span>`;
  return `<span class="chip open">${esc(c.status)}</span>`;
}
