/* 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) {
  const el = document.getElementById("banner");
  el.setAttribute("role", "status");
  el.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;
}

/* 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);
}

/* 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(" · ");
}

/* 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 === "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>`;
}
