"use strict";

/**
 * Dashboard admin metalfrom.eu.
 *
 * Organisation : un onglet = une question que se pose l'admin, et non une table
 * de la base. Chaque chiffre problématique porte son action à côté de lui —
 * l'ancienne séparation « je constate ici / j'agis dans un autre onglet » était
 * la principale source de confusion.
 *
 *   Pilotage      → est-ce que ça tourne ?
 *   Groupes       → trouver et corriger un groupe
 *   Localisations → qu'est-ce qui coince dans le géocodage ?
 *   Journal       → que s'est-il passé ?
 *   LLM & coûts   → combien ça coûte ?
 */

const state = {
  username: null,
  view: "pilotage",
  bands: {
    page: 1, pageSize: 50, q: "", location_q: "", country: "", status: "", genre: "",
    themes_q: "", enriched: "", has_lat: "", has_location: "", has_conflict: "",
    sort: "ma_id", dir: "asc", total: 0, advanced: false,
  },
  locations: { page: 1, pageSize: 50, status: "error,llm_needed", q: "", country: "", total: 0 },
  activity: { page: 1, pageSize: 50, type: "", status: "", total: 0 },
  llm: { page: 1 },
  timer: null,
};

// ------------------------------------------------------------------
// API
// ------------------------------------------------------------------
async function api(path, opts = {}) {
  const res = await fetch(path, {
    credentials: "include",
    headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
    ...opts,
  });
  if (res.status === 401) {
    showLogin();
    throw new Error("unauthorized");
  }
  const body = await res.json().catch(() => ({}));
  if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`);
  return body;
}

// ------------------------------------------------------------------
// Utilitaires de rendu
// ------------------------------------------------------------------
function esc(s) {
  return String(s ?? "").replace(/[&<>"']/g, (c) =>
    ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function fmtDate(s) {
  if (!s) return "—";
  return new Date(s).toLocaleString("fr-FR", {
    day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit",
  });
}
function fmtNum(n) {
  return Number(n || 0).toLocaleString("fr-FR");
}
/** Durée écoulée, lisible ("3 min", "2 h 10"). */
function since(ts) {
  if (!ts) return "—";
  const min = Math.max(0, Math.round((Date.now() - new Date(ts)) / 60000));
  if (min < 60) return `${min} min`;
  const h = Math.floor(min / 60);
  return `${h} h ${String(min % 60).padStart(2, "0")}`;
}
const content = () => document.getElementById("content");

const STATUS_TONE = {
  done: "ok", country_only: "ok", error: "err", running: "warn",
  cancelled: "muted", queued: "muted", processing: "warn",
  llm_needed: "warn", manual: "warn",
};
/** Les valeurs stockées sont techniques ; l'admin lit du français. */
const STATUS_LABEL = {
  done: "résolu", country_only: "pays seul", error: "erreur",
  running: "en cours", cancelled: "annulé", queued: "en file",
  processing: "traitement", llm_needed: "attente LLM", manual: "manuel",
};
function statusBadge(status) {
  return `<span class="badge ${STATUS_TONE[status] || "muted"}" title="${esc(status)}">${esc(STATUS_LABEL[status] || status)}</span>`;
}
function emptyRow(msg = "Aucune donnée") { return `<div class="empty">${esc(msg)}</div>`; }

/** Bloc d'erreur uniforme, avec le message réel de l'API. */
function errorBox(e) {
  return `<div class="empty err-box">⚠ ${esc(e.message)}</div>`;
}

function clearTimer() {
  if (state.timer) { clearInterval(state.timer); state.timer = null; }
}

/**
 * Jeton de rendu.
 *
 * Les vues chargent leurs données de façon asynchrone puis arment un
 * auto-refresh. Sans ce jeton, quitter une vue PENDANT son chargement laissait
 * le minuteur s'armer après coup : router() avait déjà fait son clearTimer(),
 * et le Pilotage continuait d'interroger quatre endpoints toutes les 15 s
 * depuis un autre onglet, indéfiniment.
 */
let renderToken = 0;
const isStale = (token) => token !== renderToken;

// ------------------------------------------------------------------
// Auth
// ------------------------------------------------------------------
function showLogin() {
  document.getElementById("login-screen").classList.remove("hidden");
  document.getElementById("app").classList.add("hidden");
  clearTimer();
}
function showApp() {
  document.getElementById("login-screen").classList.add("hidden");
  document.getElementById("app").classList.remove("hidden");
  document.getElementById("whoami").textContent = state.username || "";
}

async function checkSession() {
  try {
    const r = await api("/admin/auth/me");
    state.username = r.username;
    showApp();
    router();
  } catch {
    showLogin();
  }
}

document.getElementById("login-form").addEventListener("submit", async (e) => {
  e.preventDefault();
  const username = document.getElementById("login-username").value.trim();
  const password = document.getElementById("login-password").value;
  const errEl = document.getElementById("login-error");
  const btn = e.target.querySelector('button[type="submit"]');
  errEl.textContent = "";
  btn.disabled = true;
  try {
    const res = await fetch("/admin/auth/login", {
      method: "POST",
      credentials: "include",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });
    const body = await res.json().catch(() => ({}));
    if (!res.ok || body.ok === false) {
      errEl.textContent = body.error || "Échec de connexion";
      return;
    }
    state.username = body.username;
    document.getElementById("login-password").value = "";
    showApp();
    router();
  } catch {
    errEl.textContent = "Erreur réseau";
  } finally {
    btn.disabled = false;
  }
});

document.getElementById("logout-btn").addEventListener("click", async () => {
  try { await fetch("/admin/auth/logout", { method: "POST", credentials: "include" }); } catch {}
  state.username = null;
  showLogin();
});

// ------------------------------------------------------------------
// Routeur
// ------------------------------------------------------------------
const VIEWS = ["pilotage", "bands", "locations", "activity", "llm"];
const VIEW_ALIASES = {
  "": "pilotage", pilotage: "pilotage", dashboard: "pilotage", overview: "pilotage",
  bands: "bands", band: "bands", groupes: "bands",
  locations: "locations", localisations: "locations", geocoding: "locations",
  activity: "activity", activite: "activity", journal: "activity",
  llm: "llm", couts: "llm",
  // L'onglet « Actions » a été fondu dans Pilotage : on redirige les favoris.
  actions: "pilotage",
};

function normalizeView(raw) {
  // La query string doit être retirée AVANT la recherche d'alias : les liens du
  // Pilotage pointent vers "#/locations?status=error", qui sinon ne
  // correspondrait à aucune vue et retomberait sur Pilotage.
  const key = String(raw || "").split("?")[0]
    .trim().toLowerCase().replace(/^\/+|\/+$/g, "");
  const normalized = VIEW_ALIASES[key];
  return VIEWS.includes(normalized) ? normalized : "pilotage";
}

function currentViewFromLocation() {
  if (location.hash) return normalizeView((location.hash || "").replace(/^#\/?/, ""));
  return normalizeView(location.pathname.split("/").filter(Boolean)[0] || "");
}

const RENDERERS = {
  pilotage: renderPilotage,
  bands: renderBands,
  locations: renderLocations,
  activity: renderActivity,
  llm: renderLLM,
};

function router() {
  const view = currentViewFromLocation();
  state.view = view;
  const token = ++renderToken;
  document.querySelectorAll(".nav a").forEach((a) => {
    const active = a.dataset.view === view;
    a.classList.toggle("active", active);
    if (active) a.setAttribute("aria-current", "page");
    else a.removeAttribute("aria-current");
  });
  clearTimer();
  RENDERERS[view](token);
}
window.addEventListener("hashchange", router);

// ==================================================================
// PILOTAGE — « est-ce que ça tourne ? »
// ==================================================================
async function renderPilotage(token = renderToken) {
  content().innerHTML = `
    <div class="view-head">
      <h1>Pilotage</h1>
      <span id="p-refresh" class="refresh-note" aria-live="polite">Actualisation auto toutes les 15 s</span>
    </div>
    <div id="p-health" class="health-strip"><div class="loading">Chargement…</div></div>
    <div id="p-alerts"></div>
    <div class="grid grid-2">
      <div class="card" id="p-runs"><div class="loading">Chargement…</div></div>
      <div class="card" id="p-triggers"></div>
    </div>
    <details class="card danger-zone" id="p-danger">
      <summary>⚠️ Opérations destructives</summary>
      <p class="muted-note">
        À n'utiliser que si le géocodage est corrompu. Ces actions relancent des
        milliers d'appels Geoapify et Groq, qui sont facturés.
      </p>
      <div class="btn-row">
        <button class="btn btn-danger" data-danger="reset-all">🔥 Réinitialiser tout le géocodage</button>
        <button class="btn btn-danger" data-danger="purge-nominatim">🧹 Purger le cache Nominatim</button>
        <button class="btn btn-danger" data-danger="requeue-all">♻️ Remettre TOUT en file (dont « done »)</button>
      </div>
      <div id="p-danger-feedback" class="feedback" role="status"></div>
    </details>
  `;

  renderTriggers();
  wireDangerZone();
  await loadPilotage();
  if (isStale(token)) return;   // l'utilisateur a changé de vue pendant le chargement
  clearTimer();
  state.timer = setInterval(loadPilotage, 15000);
}

/**
 * Les trois premiers traitements sont consommés par le crawler. S'il n'est pas
 * déployé sur cet environnement (c'est le cas de la production, dont le compose
 * ne contient pas le service), les demandes resteraient « en attente » pour
 * toujours — le bouton promettait une exécution qui n'arrivait jamais.
 * On s'appuie sur le battement de cœur : pas de crawler vivant, pas de bouton.
 */
function renderTriggers() {
  document.getElementById("p-triggers").innerHTML = `
    <h2>Lancer un traitement</h2>
    <p class="muted-note" id="p-triggers-note">Le crawler consomme la demande à son prochain cycle (~1 min).</p>
    <div class="btn-col">
      <button class="btn" data-job="incremental">🕷 Crawl incrémental <span class="hint">créations et modifications récentes</span></button>
      <button class="btn" data-job="enrich">📖 Enrichir les groupes <span class="hint">visite les fiches non enrichies</span></button>
      <!-- L'enqueue est traitée par le geocoder, pas par le crawler : jamais désactivée ici. -->
      <button class="btn" data-job="geocoder_enqueue">🗺 Alimenter le géocodage <span class="hint">reparse les lieux en attente</span></button>
      <button class="btn" data-job="full_crawl">🌍 Crawl complet Europe <span class="hint">long — plusieurs heures</span></button>
    </div>
    <div id="p-job-feedback" class="feedback" role="status"></div>
  `;

  document.getElementById("p-triggers").addEventListener("click", async (e) => {
    const btn = e.target.closest("[data-job]");
    if (!btn) return;
    const job_type = btn.dataset.job;
    if (job_type === "full_crawl" &&
        !confirm("Lancer un crawl complet de l'Europe ?\n\nPlusieurs heures, et beaucoup de requêtes vers Metal Archives.")) return;
    const fb = document.getElementById("p-job-feedback");
    btn.disabled = true;
    fb.textContent = "Envoi…"; fb.className = "feedback";
    try {
      const r = await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type }) });
      fb.textContent = `✓ Demande #${r.id} enregistrée — suivi dans Journal.`;
      fb.className = "feedback ok";
      await loadPilotage();
    } catch (err) {
      fb.textContent = `✗ ${err.message}`;
      fb.className = "feedback err";
    } finally {
      btn.disabled = false;
    }
  });
}

/**
 * Active ou désactive les traitements consommés par le crawler.
 *
 * Modifie l'ÉTAT des boutons sans reconstruire le bloc : le re-rendre effacerait
 * le message de retour qu'on vient d'y afficher (« Demande #77 enregistrée »),
 * puisque loadPilotage() est rappelé juste après un clic réussi.
 */
function updateTriggerAvailability(services) {
  const crawler = services.find((x) => x.service === "crawler");
  const absent = !crawler;
  const down = crawler && (crawler.stale || !crawler.ok);
  const why = absent
    ? "Aucun crawler déployé sur cet environnement : la demande ne serait jamais consommée."
    : down
      ? "Le crawler est silencieux ou dégradé : la demande resterait en attente."
      : "";

  // geocoder_enqueue est traité par le geocoder, pas par le crawler.
  for (const job of ["incremental", "enrich", "full_crawl"]) {
    const btn = document.querySelector(`[data-job="${job}"]`);
    if (!btn) continue;
    btn.disabled = Boolean(why);
    btn.title = why;
  }
  const note = document.getElementById("p-triggers-note");
  if (note) {
    note.textContent = why
      ? `⚠ ${why}`
      : "Le crawler consomme la demande à son prochain cycle (~1 min).";
  }
}

const DANGER_ACTIONS = {
  "reset-all": {
    url: "/admin/api/locations/reset-all",
    confirm: "RÉINITIALISER TOUT LE GÉOCODAGE ?\n\nToutes les localisations repassent en file et perdent leurs coordonnées. Le cache est réutilisé quand c'est possible, mais cela reste coûteux.",
  },
  "purge-nominatim": {
    url: "/admin/api/geocode-cache/purge-nominatim",
    confirm: "Supprimer les entrées de cache de l'ancien pipeline Nominatim ?\n\nCes lieux seront re-géocodés via Geoapify — appels facturés.",
  },
  "requeue-all": {
    url: "/admin/api/locations/requeue-all",
    body: { include_done: true },
    confirm: "Remettre TOUTES les localisations en file, y compris celles déjà résolues ?",
  },
};

function wireDangerZone() {
  document.getElementById("p-danger").addEventListener("click", async (e) => {
    const btn = e.target.closest("[data-danger]");
    if (!btn) return;
    const action = DANGER_ACTIONS[btn.dataset.danger];
    if (!action || !confirm(action.confirm)) return;
    const fb = document.getElementById("p-danger-feedback");
    btn.disabled = true;
    fb.textContent = "En cours…"; fb.className = "feedback";
    try {
      const r = await api(action.url, { method: "POST", body: JSON.stringify(action.body || {}) });
      fb.textContent = `✓ ${fmtNum(r.count)} entrée(s) modifiée(s).`;
      fb.className = "feedback ok";
      await loadPilotage();
    } catch (err) {
      fb.textContent = `✗ ${err.message}`;
      fb.className = "feedback err";
    } finally {
      btn.disabled = false;
    }
  });
}

/**
 * État des services de fond (crawler, geocoder…).
 *
 * Ils ne sont pas exposés par Traefik : sans ce battement de cœur, un crawler
 * dont FlareSolverr est injoignable reste invisible, et le seul symptôme est
 * l'absence de données nouvelles. Un contrôle trop ancien vaut une panne :
 * un service arrêté cesse simplement de mettre sa ligne à jour.
 */
function servicesCard(services) {
  if (!services.length) {
    return healthCard("Services", "—", "aucun battement de cœur reçu", "warn");
  }
  const ko = services.filter((s) => !s.ok || s.stale);
  const detail = ko.length
    ? ko.map((s) => `${esc(s.service)} : ${s.stale ? "silencieux" : esc(s.error || "dégradé")}`).join(" · ")
    : services.map((s) => esc(s.service)).join(" · ");
  return healthCard("Services", `${services.length - ko.length}/${services.length}`, detail,
    ko.length ? "err" : "ok");
}

function healthCard(label, value, sub, tone = "") {
  return `<div class="health-card ${tone}">
    <div class="h-label">${esc(label)}</div>
    <div class="h-value">${value}</div>
    <div class="h-sub">${sub}</div>
  </div>`;
}

async function loadPilotage() {
  try {
    const [stats, live, geo, queue] = await Promise.all([
      api("/admin/api/stats"),
      api("/admin/api/live"),
      api("/admin/api/geocoding"),
      api("/admin/api/queue"),
    ]);

    const t = stats.totals;
    const locs = geo.locations || [];
    const n = (s) => locs.find((x) => x.status === s)?.n || 0;
    const locTotal = locs.reduce((sum, x) => sum + x.n, 0);
    const locDone = n("done") + n("country_only");
    const locPct = locTotal ? Math.round((locDone / locTotal) * 100) : 0;
    const errors = n("error");
    const llmNeeded = n("llm_needed");
    const queued = n("queued") + n("processing");
    const cost = Number(geo.llm_cache?.total_cost_usd || 0);

    // La vue a pu être remplacée pendant les requêtes.
    const healthEl = document.getElementById("p-health");
    if (!healthEl) return;

    // ---- Bandeau de santé ----
    healthEl.innerHTML = [
      healthCard("Groupes", fmtNum(t.total), `${fmtNum(t.enriched)} enrichis`),
      healthCard("Géocodage", `${locPct} %`,
        `${fmtNum(locDone)} / ${fmtNum(locTotal)} lieux`,
        locPct >= 95 ? "ok" : locPct >= 70 ? "warn" : "err"),
      healthCard("En file", fmtNum(queued),
        queued ? "traitement en cours" : "rien à traiter",
        queued ? "warn" : "ok"),
      healthCard("Bloqués", fmtNum(errors + llmNeeded),
        `${fmtNum(errors)} erreurs · ${fmtNum(llmNeeded)} LLM`,
        errors + llmNeeded === 0 ? "ok" : "err"),
      healthCard("Coût LLM", `$${cost.toFixed(2)}`, `${fmtNum(geo.llm_cache?.n || 0)} appels`),
      servicesCard(live.services || []),
    ].join("");

    // ---- Alertes actionnables ----
    // Le principe de cette vue : aucun chiffre problématique sans le bouton qui
    // le résout juste à côté.
    const alerts = [];
    if (errors > 0) {
      alerts.push({
        tone: "err",
        text: `${fmtNum(errors)} localisation(s) en erreur de géocodage`,
        actions: [
          { label: "Examiner", href: "#/locations?status=error" },
          { label: "Tout remettre en file", post: "/admin/api/locations/reset-errors" },
        ],
      });
    }
    if (llmNeeded > 0) {
      alerts.push({
        tone: "warn",
        text: `${fmtNum(llmNeeded)} lieu(x) en attente de désambiguïsation LLM`,
        actions: [{ label: "Examiner", href: "#/locations?status=llm_needed" }],
      });
    }
    if (t.never_enriched > 0) {
      alerts.push({
        tone: "warn",
        text: `${fmtNum(t.never_enriched)} groupe(s) jamais enrichis`,
        actions: [{ label: "Lancer l'enrichissement", job: "enrich" }],
      });
    }
    if (queue.breakdown?.stale > 0) {
      alerts.push({
        tone: "muted",
        text: `${fmtNum(queue.breakdown.stale)} groupe(s) non revus depuis plus de 30 jours`,
        actions: [{ label: "Crawl incrémental", job: "incremental" }],
      });
    }
    for (const svc of (live.services || []).filter((x) => !x.ok || x.stale)) {
      alerts.push({
        tone: "err",
        text: svc.stale
          ? `Service « ${svc.service} » silencieux depuis plus de 2 h — arrêté ou bloqué`
          : `Service « ${svc.service} » dégradé : ${svc.error || "sonde en échec"}`,
        actions: [{ label: "Voir le journal", href: "#/activity" }],
      });
    }

    const stuck = (live.active_runs || []).filter(
      (r) => Date.now() - new Date(r.started_at) > 30 * 60000 && !r.cancel_requested);
    if (stuck.length) {
      alerts.push({
        tone: "err",
        text: `${stuck.length} run(s) actifs depuis plus de 30 min — possiblement bloqués`,
        actions: [{ label: "Nettoyer les runs bloqués", post: "/admin/api/crawl-runs/cleanup", body: { older_than_minutes: 30 } }],
      });
    }

    document.getElementById("p-alerts").innerHTML = alerts.length
      ? `<div class="alerts">${alerts.map((a, idx) => `
          <div class="alert alert-${a.tone}">
            <span class="alert-text">${esc(a.text)}</span>
            <span class="alert-actions">
              ${a.actions.map((act, j) => act.href
                ? `<a class="btn btn-mini" href="${esc(act.href)}">${esc(act.label)}</a>`
                : `<button class="btn btn-mini" data-alert="${idx}" data-act="${j}">${esc(act.label)}</button>`
              ).join("")}
            </span>
          </div>`).join("")}</div>`
      : `<div class="alerts"><div class="alert alert-ok"><span class="alert-text">✓ Rien ne bloque : le pipeline est sain.</span></div></div>`;

    document.getElementById("p-alerts").onclick = async (e) => {
      const btn = e.target.closest("[data-alert]");
      if (!btn) return;
      const act = alerts[Number(btn.dataset.alert)].actions[Number(btn.dataset.act)];
      btn.disabled = true;
      try {
        if (act.job) {
          await api("/admin/api/job-triggers", { method: "POST", body: JSON.stringify({ job_type: act.job }) });
        } else {
          await api(act.post, { method: "POST", body: JSON.stringify(act.body || {}) });
        }
        await loadPilotage();
      } catch (err) {
        alert(err.message);
        btn.disabled = false;
      }
    };

    // ---- Runs actifs ----
    const runs = live.active_runs || [];
    document.getElementById("p-runs").innerHTML = `
      <h2>Traitements en cours <span class="count">(${runs.length})</span></h2>
      ${runs.length ? runs.map((r) => {
        const pending = r.cancel_requested === true;
        return `<div class="run-row">
          <div>
            <span class="badge ${pending ? "muted" : "warn"}">▶ ${esc(r.run_type)}</span>
            <div class="run-meta">
              ${since(r.started_at)} · ${fmtNum(r.bands_seen)} vus · ${fmtNum(r.bands_new)} nouveaux · ${fmtNum(r.bands_enriched)} enrichis
            </div>
            ${pending ? `<div class="run-cancel-note">⏳ Arrêt demandé${r.cancel_requested_by ? ` par ${esc(r.cancel_requested_by)}` : ""} — s'arrêtera au prochain lot</div>` : ""}
          </div>
          ${pending
            ? `<button class="btn btn-mini" disabled>⏳ Arrêt…</button>`
            : `<button class="btn btn-mini btn-danger" data-cancel-run="${r.id}">Arrêter</button>`}
        </div>`;
      }).join("") : emptyRow("Aucun traitement en cours")}

      ${(live.pending_jobs || []).length ? `
        <h2 class="mt">En attente <span class="count">(${live.pending_jobs.length})</span></h2>
        ${live.pending_jobs.map((j) => `<div class="run-row">
          <div>
            <span class="badge muted">${esc(j.job_type)}</span>
            <div class="run-meta">demandé par ${esc(j.requested_by || "?")} · il y a ${since(j.created_at)}</div>
          </div>
          <button class="btn btn-mini" data-cancel-job="${j.id}">Retirer</button>
        </div>`).join("")}` : ""}
    `;

    updateTriggerAvailability(live.services || []);

    document.getElementById("p-runs").onclick = async (e) => {
      const runBtn = e.target.closest("[data-cancel-run]");
      const jobBtn = e.target.closest("[data-cancel-job]");
      if (!runBtn && !jobBtn) return;
      const id = Number((runBtn || jobBtn).dataset.cancelRun || (runBtn || jobBtn).dataset.cancelJob);
      const url = runBtn ? `/admin/api/crawl-runs/${id}/cancel` : `/admin/api/job-triggers/${id}/cancel`;
      const msg = runBtn
        ? `Demander l'arrêt du traitement #${id} ?\n\nIl s'arrêtera à son prochain point de contrôle.`
        : `Retirer la demande #${id} de la file ?`;
      if (!confirm(msg)) return;
      (runBtn || jobBtn).disabled = true;
      try { await api(url, { method: "POST", body: "{}" }); await loadPilotage(); }
      catch (err) { alert(err.message); await loadPilotage(); }
    };

    const note = document.getElementById("p-refresh");
    if (note) note.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`;
  } catch (e) {
    const note = document.getElementById("p-refresh");
    if (note) note.textContent = `Erreur : ${e.message}`;
  }
}

// ==================================================================
// GROUPES — « trouver et corriger »
// ==================================================================
const BAND_CHIPS = [
  { key: "has_conflict", value: "true", label: "🔒 Conflits crawler" },
  { key: "has_lat", value: "false", label: "Sans coordonnées" },
  { key: "has_location", value: "false", label: "Sans lieu renseigné" },
  { key: "enriched", value: "false", label: "Non enrichis" },
];

async function renderBands() {
  const s = state.bands;
  content().innerHTML = `
    <div class="view-head"><h1>Groupes</h1><span id="b-count" class="refresh-note"></span></div>

    <div class="search-bar">
      <input type="search" id="b-q" placeholder="Rechercher un groupe (nom ou genre)…" value="${esc(s.q)}" aria-label="Rechercher un groupe">
      <button class="btn btn-primary-sm" id="b-search">Rechercher</button>
      <button class="btn btn-mini" id="b-advanced-toggle" aria-expanded="${s.advanced}">Filtres avancés</button>
    </div>

    <div class="chips" role="group" aria-label="Filtres rapides">
      ${BAND_CHIPS.map((c) => `<button class="chip ${s[c.key] === c.value ? "on" : ""}" data-chip="${c.key}" data-val="${c.value}" aria-pressed="${s[c.key] === c.value}">${esc(c.label)}</button>`).join("")}
      <button class="chip chip-clear" id="b-clear">Tout effacer</button>
    </div>

    <div class="toolbar ${s.advanced ? "" : "hidden"}" id="b-advanced">
      <input type="text" id="b-country" placeholder="Pays (FR, DE…)" value="${esc(s.country)}" aria-label="Pays">
      <input type="text" id="b-status" placeholder="Statut" value="${esc(s.status)}" aria-label="Statut">
      <input type="text" id="b-genre" placeholder="Genre" value="${esc(s.genre)}" aria-label="Genre">
      <input type="text" id="b-location_q" placeholder="Lieu…" value="${esc(s.location_q)}" aria-label="Lieu">
      <input type="text" id="b-themes_q" placeholder="Thèmes…" value="${esc(s.themes_q)}" aria-label="Thèmes">
      <button class="btn btn-mini" id="b-apply">Appliquer</button>
    </div>

    <div id="b-table" class="table-wrap"><div class="loading">Chargement…</div></div>
    <div class="pager" id="b-pager"></div>
  `;

  const applySearch = () => { s.q = document.getElementById("b-q").value.trim(); s.page = 1; loadBands(); };
  document.getElementById("b-search").addEventListener("click", applySearch);
  document.getElementById("b-q").addEventListener("keydown", (e) => { if (e.key === "Enter") applySearch(); });

  document.getElementById("b-advanced-toggle").addEventListener("click", (e) => {
    s.advanced = !s.advanced;
    document.getElementById("b-advanced").classList.toggle("hidden", !s.advanced);
    e.target.setAttribute("aria-expanded", String(s.advanced));
  });

  document.querySelector(".chips").addEventListener("click", (e) => {
    const chip = e.target.closest("[data-chip]");
    if (chip) {
      const { chip: key, val } = chip.dataset;
      s[key] = s[key] === val ? "" : val;
      s.page = 1;
      renderBands();
      return;
    }
    if (e.target.id === "b-clear") {
      for (const c of BAND_CHIPS) s[c.key] = "";
      s.q = s.country = s.status = s.genre = s.location_q = s.themes_q = "";
      s.page = 1;
      renderBands();
    }
  });

  document.getElementById("b-apply")?.addEventListener("click", () => {
    for (const f of ["country", "status", "genre", "location_q", "themes_q"]) {
      s[f] = document.getElementById(`b-${f}`).value.trim();
    }
    s.page = 1;
    loadBands();
  });

  await loadBands();
}

const BAND_COLUMNS = [
  { key: "ma_id", label: "ID" },
  { key: "name", label: "Nom" },
  { key: "country", label: "Pays" },
  { key: "status", label: "Statut" },
  { key: "genre", label: "Genre" },
  { key: "location_text", label: "Lieu" },
  { key: "formed_year", label: "Année" },
  { key: "enriched", label: "État" },
  { key: "updated_at", label: "Modifié" },
];

async function loadBands() {
  const s = state.bands;
  const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize, sort: s.sort, dir: s.dir });
  for (const f of ["q", "location_q", "country", "status", "genre", "themes_q", "enriched", "has_lat", "has_location", "has_conflict"]) {
    if (s[f]) params.set(f, s[f]);
  }

  try {
    const r = await api(`/admin/api/bands?${params}`);
    s.total = r.total;
    document.getElementById("b-count").textContent = `${fmtNum(r.total)} groupe(s)`;

    document.getElementById("b-table").innerHTML = `
      <table>
        <thead><tr>
          ${BAND_COLUMNS.map((c) => `<th><button class="th-sort" data-sort="${c.key}">${c.label}${s.sort === c.key ? (s.dir === "asc" ? " ▲" : " ▼") : ""}</button></th>`).join("")}
        </tr></thead>
        <tbody>
          ${r.items.map((b) => `
            <tr class="clickable" data-ma-id="${b.ma_id}" tabindex="0" role="button" aria-label="Ouvrir ${esc(b.name)}">
              <td>${b.ma_id}</td>
              <td>${esc(b.name)}</td>
              <td>${esc(b.country || "")}</td>
              <td>${esc(b.status || "")}</td>
              <td class="truncate" title="${esc(b.genre || "")}">${esc(b.genre || "")}</td>
              <td class="truncate" title="${esc(b.location_text || "")}">${esc(b.location_text || "")}</td>
              <td>${b.formed_year || ""}</td>
              <td>
                ${b.enriched ? '<span class="badge ok">enrichi</span>' : '<span class="badge warn">à enrichir</span>'}
                ${b.lat == null ? '<span class="badge muted" title="Pas de coordonnées">📍?</span>' : ""}
                ${Object.keys(b.locked_fields || {}).length ? '<span class="lock" title="Champs verrouillés manuellement">🔒</span>' : ""}
              </td>
              <td>${fmtDate(b.updated_at)}</td>
            </tr>`).join("") || `<tr><td colspan="${BAND_COLUMNS.length}" class="empty">Aucun résultat</td></tr>`}
        </tbody>
      </table>`;

    const tableEl = document.getElementById("b-table");
    tableEl.querySelectorAll("[data-sort]").forEach((th) => {
      th.addEventListener("click", () => {
        const key = th.dataset.sort;
        if (s.sort === key) s.dir = s.dir === "asc" ? "desc" : "asc";
        else { s.sort = key; s.dir = "asc"; }
        loadBands();
      });
    });
    // Les lignes sont activables au clavier (role=button + tabindex), pas
    // seulement à la souris.
    tableEl.querySelectorAll("tr[data-ma-id]").forEach((tr) => {
      const open = () => openBandModal(Number(tr.dataset.maId));
      tr.addEventListener("click", open);
      tr.addEventListener("keydown", (e) => {
        if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); }
      });
    });

    renderPager("b-pager", s, loadBands);
  } catch (e) {
    document.getElementById("b-table").innerHTML = errorBox(e);
  }
}

/** Pagination partagée par les trois listes. */
function renderPager(elId, s, reload) {
  const totalPages = Math.max(1, Math.ceil(s.total / s.pageSize));
  const el = document.getElementById(elId);
  if (!el) return;
  el.innerHTML = `
    <button class="btn btn-mini" data-page="prev" ${s.page <= 1 ? "disabled" : ""}>← Précédent</button>
    <span>Page ${s.page} / ${totalPages} — ${fmtNum(s.total)} résultat(s)</span>
    <button class="btn btn-mini" data-page="next" ${s.page >= totalPages ? "disabled" : ""}>Suivant →</button>`;
  el.onclick = (e) => {
    const btn = e.target.closest("[data-page]");
    if (!btn || btn.disabled) return;
    s.page += btn.dataset.page === "next" ? 1 : -1;
    reload();
  };
}

// ==================================================================
// LOCALISATIONS — « qu'est-ce qui coince dans le géocodage ? »
// ==================================================================
const LOCATION_FILTERS = [
  { value: "error,llm_needed", label: "À débloquer" },
  { value: "error", label: "En erreur" },
  { value: "llm_needed", label: "Attente LLM" },
  { value: "queued,processing", label: "En file" },
  { value: "manual", label: "Manuel" },
  { value: "done,country_only", label: "Résolues" },
  { value: "", label: "Toutes" },
];

async function renderLocations() {
  const s = state.locations;

  // Les alertes du Pilotage pointent sur un statut précis (#/locations?status=error).
  // Ce paramètre est CONSOMMÉ : on le retire de l'URL une fois appliqué, sinon
  // chaque rendu suivant le réappliquerait et annulerait les clics sur les
  // chips — arrivé depuis une alerte, le filtre devenait impossible à changer.
  // replaceState ne déclenche pas hashchange : pas de boucle de rendu.
  const [hashBase, hashQuery] = location.hash.split("?");
  if (hashQuery) {
    const fromHash = new URLSearchParams(hashQuery);
    if (fromHash.has("status")) { s.status = fromHash.get("status"); s.page = 1; }
    history.replaceState(null, "", hashBase || "#/locations");
  }

  content().innerHTML = `
    <div class="view-head">
      <h1>Localisations</h1>
      <span id="l-count" class="refresh-note"></span>
    </div>
    <p class="muted-note">
      Une ligne par lieu à géocoder. C'est ici qu'on débloque un lieu précis,
      sans relancer des milliers d'appels facturés.
    </p>

    <div class="search-bar">
      <input type="search" id="l-q" placeholder="Filtrer par lieu ou nom de groupe…" value="${esc(s.q)}" aria-label="Filtrer les localisations">
      <input type="text" id="l-country" placeholder="Pays" value="${esc(s.country)}" style="width:90px" aria-label="Pays">
      <button class="btn btn-primary-sm" id="l-search">Filtrer</button>
    </div>

    <div class="chips" role="group" aria-label="Filtrer par statut">
      ${LOCATION_FILTERS.map((f) => `<button class="chip ${s.status === f.value ? "on" : ""}" data-status="${esc(f.value)}" aria-pressed="${s.status === f.value}">${esc(f.label)}</button>`).join("")}
    </div>

    <div id="l-table" class="table-wrap"><div class="loading">Chargement…</div></div>
    <div class="pager" id="l-pager"></div>
  `;

  const apply = () => {
    s.q = document.getElementById("l-q").value.trim();
    s.country = document.getElementById("l-country").value.trim();
    s.page = 1;
    loadLocations();
  };
  document.getElementById("l-search").addEventListener("click", apply);
  document.getElementById("l-q").addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); });

  document.querySelector(".chips").addEventListener("click", (e) => {
    const chip = e.target.closest("[data-status]");
    if (!chip) return;
    s.status = chip.dataset.status;
    s.page = 1;
    renderLocations();
  });

  await loadLocations();
}

async function loadLocations() {
  const s = state.locations;
  const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize });
  if (s.status) params.set("status", s.status);
  if (s.q) params.set("q", s.q);
  if (s.country) params.set("country", s.country);

  try {
    const r = await api(`/admin/api/locations?${params}`);
    s.total = r.total;
    document.getElementById("l-count").textContent = `${fmtNum(r.total)} localisation(s)`;

    document.getElementById("l-table").innerHTML = `
      <table>
        <thead><tr>
          <th>Groupe</th><th>Lieu brut</th><th>Statut</th><th>Coordonnées</th>
          <th>Essais</th><th>Dernière erreur</th><th>Actions</th>
        </tr></thead>
        <tbody>
          ${r.items.map((l) => `
            <tr data-loc-id="${l.id}">
              <td>
                <button class="link-btn" data-open-band="${l.ma_id}">${esc(l.band_name || "#" + l.ma_id)}</button>
                <div class="sub">${esc(l.country || "")}${l.step_label ? ` · ${esc(l.step_label)}` : ""}</div>
              </td>
              <td class="truncate" title="${esc(l.location_raw)}">${esc(l.location_raw)}</td>
              <td>${statusBadge(l.geocode_status)}</td>
              <td class="mono">${l.lat != null && l.lon != null ? `${Number(l.lat).toFixed(4)}, ${Number(l.lon).toFixed(4)}` : "—"}
                <div class="sub">${esc(l.geocode_provider || "")}${l.geocode_confidence != null ? ` · ${Number(l.geocode_confidence).toFixed(2)}` : ""}</div>
              </td>
              <td class="nowrap sub">${l.geocode_tries_geo || 0} géo · ${l.geocode_tries_llm || 0} LLM</td>
              <td class="truncate err-text" title="${esc(l.geocode_error || "")}">${esc(l.geocode_error || "—")}</td>
              <td class="nowrap">
                <button class="btn btn-mini" data-requeue="${l.id}">Relancer</button>
                <button class="btn btn-mini" data-setcoords="${l.id}">Coordonnées…</button>
              </td>
            </tr>`).join("") || `<tr><td colspan="7" class="empty">Aucune localisation pour ce filtre</td></tr>`}
        </tbody>
      </table>`;

    document.getElementById("l-table").onclick = (e) => {
      const band = e.target.closest("[data-open-band]");
      if (band) return openBandModal(Number(band.dataset.openBand));
      const rq = e.target.closest("[data-requeue]");
      if (rq) return requeueLocation(Number(rq.dataset.requeue), rq);
      const sc = e.target.closest("[data-setcoords]");
      if (sc) {
        const id = Number(sc.dataset.setcoords);
        return openCoordsModal(r.items.find((x) => x.id === id));
      }
    };

    renderPager("l-pager", s, loadLocations);
  } catch (e) {
    document.getElementById("l-table").innerHTML = errorBox(e);
  }
}

async function requeueLocation(id, btn) {
  btn.disabled = true;
  try {
    await api(`/admin/api/locations/${id}/requeue`, { method: "POST", body: "{}" });
    await loadLocations();
  } catch (e) {
    alert(e.message);
    btn.disabled = false;
  }
}

function openCoordsModal(loc) {
  if (!loc) return;
  const body = `
    <p class="muted-note">
      Saisir les coordonnées à la main pour <strong>${esc(loc.location_raw)}</strong>
      (${esc(loc.band_name || "#" + loc.ma_id)}). Le lieu sera marqué comme résolu
      par « admin » et ne repassera plus par le géocodeur.
    </p>
    <label>Latitude<input type="number" step="any" id="c-lat" value="${loc.lat ?? ""}" required></label>
    <label>Longitude<input type="number" step="any" id="c-lon" value="${loc.lon ?? ""}" required></label>
    <div class="modal-error" id="c-error" role="alert"></div>
    <div class="modal-actions">
      <button class="btn btn-mini" data-close>Annuler</button>
      <button class="btn btn-primary-sm" id="c-save">Enregistrer</button>
    </div>`;

  const modal = openModal(`Coordonnées — ${esc(loc.location_raw)}`, body);
  modal.querySelector("#c-save").addEventListener("click", async () => {
    const errEl = modal.querySelector("#c-error");
    errEl.textContent = "";
    try {
      await api(`/admin/api/locations/${loc.id}`, {
        method: "PATCH",
        body: JSON.stringify({
          lat: modal.querySelector("#c-lat").value,
          lon: modal.querySelector("#c-lon").value,
        }),
      });
      closeModal(modal);
      await loadLocations();
    } catch (e) {
      errEl.textContent = e.message;
    }
  });
}

// ==================================================================
// Modales — ouverture, fermeture, piège à focus
// ==================================================================
let lastFocused = null;

function openModal(title, bodyHtml, extraClass = "") {
  lastFocused = document.activeElement;
  const backdrop = document.createElement("div");
  backdrop.className = "modal-backdrop";
  backdrop.innerHTML = `
    <div class="modal ${extraClass}" role="dialog" aria-modal="true" aria-label="${esc(title)}">
      <button class="modal-close" data-close aria-label="Fermer">&times;</button>
      <h3>${title}</h3>
      ${bodyHtml}
    </div>`;
  document.body.appendChild(backdrop);

  backdrop.addEventListener("click", (e) => {
    if (e.target === backdrop || e.target.closest("[data-close]")) closeModal(backdrop);
  });
  // Échap ferme, Tab reste enfermé dans la modale : sans ça le focus repartait
  // derrière le voile, invisible.
  backdrop.addEventListener("keydown", (e) => {
    if (e.key === "Escape") { closeModal(backdrop); return; }
    if (e.key !== "Tab") return;
    const focusables = backdrop.querySelectorAll(
      'button, [href], input, select, textarea, summary, [tabindex]:not([tabindex="-1"])');
    if (!focusables.length) return;
    const first = focusables[0];
    const last = focusables[focusables.length - 1];
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  });

  const target = backdrop.querySelector("input, button:not(.modal-close)");
  (target || backdrop.querySelector(".modal-close")).focus();
  return backdrop;
}

function closeModal(backdrop) {
  backdrop.remove();
  if (lastFocused && document.contains(lastFocused)) lastFocused.focus();
}

// ==================================================================
// Modal groupe (édition + provenance)
// ==================================================================
function conflictRowsHtml(band) {
  const pending = band.crawler_pending || {};
  return Object.entries(pending).map(([field, crawlerVal]) => `
    <tr>
      <td><strong>${esc(field)}</strong></td>
      <td>${esc(String(band[field] ?? "—"))}</td>
      <td class="warn-text">${esc(String(crawlerVal))}</td>
      <td class="nowrap">
        <button class="btn btn-mini" data-conflict-action="keep_mine" data-field="${esc(field)}">Garder</button>
        <button class="btn btn-mini btn-danger" data-conflict-action="accept_crawler" data-field="${esc(field)}">Accepter MA</button>
      </td>
    </tr>`).join("");
}

async function openBandModal(maId) {
  let band, locations = [], llmCalls = [];
  try {
    const r = await api(`/admin/api/bands/${maId}`);
    band = r.item; locations = r.locations || []; llmCalls = r.llm || [];
  } catch (e) {
    alert(e.message);
    return;
  }

  const lockedFields = (() => {
    const lf = band.locked_fields;
    if (!lf) return [];
    if (Array.isArray(lf)) return lf;
    if (typeof lf === "object") return Object.keys(lf).filter((k) => lf[k]);
    try { const p = JSON.parse(lf); return Array.isArray(p) ? p : Object.keys(p); } catch { return []; }
  })();

  const conflictRows = conflictRowsHtml(band);
  const fields = [
    ["name", "Nom", "text"], ["country", "Pays", "text"], ["status", "Statut", "text"],
    ["genre", "Genre", "text"], ["formed_year", "Année de formation", "number"],
    ["location_text", "Localisation (texte)", "text"], ["themes", "Thèmes", "text"],
    ["lat", "Latitude", "number"], ["lon", "Longitude", "number"],
  ];

  const body = `
    ${conflictRows ? `
      <section class="modal-section conflicts">
        <h4>⚠️ Conflits en attente d'arbitrage</h4>
        <p class="muted-note">Champs verrouillés pour lesquels Metal Archives propose une autre valeur.</p>
        <div class="table-wrap"><table>
          <thead><tr><th>Champ</th><th>Valeur actuelle</th><th>Metal Archives</th><th>Action</th></tr></thead>
          <tbody id="m-conflict-body">${conflictRows}</tbody>
        </table></div>
      </section>` : ""}

    <section class="modal-section">
      <h4>Fiche</h4>
      <div class="field-grid">
        ${fields.map(([k, label, type]) => `
          <label>${esc(label)}
            <input type="${type}" ${type === "number" ? 'step="any"' : ""} id="m-${k}" value="${esc(band[k] ?? "")}">
          </label>`).join("")}
      </div>
      <p class="muted-note">
        Modifier un champ le verrouille : le crawler ne l'écrasera plus.
        Saisir des coordonnées crée aussi un point manuel prioritaire sur la carte.
      </p>
    </section>

    <details class="modal-section">
      <summary>Provenance et géocodage</summary>
      <div class="src-grid">
        <div><span>Enrichi</span><span>${band.enriched ? "oui" : "non"}</span></div>
        <div><span>Dernier crawl</span><span>${fmtDate(band.crawled_at)}</span></div>
        <div><span>MA créé / modifié</span><span>${fmtDate(band.ma_created_at)} / ${fmtDate(band.ma_modified_at)}</span></div>
        <div><span>Champs verrouillés</span><span>${lockedFields.length ? esc(lockedFields.join(", ")) : "—"}</span></div>
        <div><span>Point principal</span><span>${esc(band.geocode_provider || "—")} · ${fmtDate(band.geocoded_at)}</span></div>
      </div>

      <h4 class="mt">Localisations (${locations.length})</h4>
      <div class="table-wrap"><table>
        <thead><tr><th>Étape</th><th>Lieu brut</th><th>Statut</th><th>Provider</th><th>Conf.</th><th>Coordonnées</th></tr></thead>
        <tbody>
          ${locations.map((l) => `<tr>
            <td>${l.step_order}${l.step_label ? ` <span class="sub">(${esc(l.step_label)})</span>` : ""}</td>
            <td>${esc(l.location_raw)}${l.geocode_error ? `<div class="err-text sub">${esc(l.geocode_error)}</div>` : ""}</td>
            <td>${statusBadge(l.geocode_status)}</td>
            <td>${esc(l.geocode_provider || "—")}</td>
            <td>${l.geocode_confidence != null ? Number(l.geocode_confidence).toFixed(2) : "—"}</td>
            <td class="mono">${l.lat != null ? `${Number(l.lat).toFixed(3)}, ${Number(l.lon).toFixed(3)}` : "—"}</td>
          </tr>`).join("") || `<tr><td colspan="6" class="empty">Aucune localisation</td></tr>`}
        </tbody>
      </table></div>

      <h4 class="mt">Appels LLM (${llmCalls.length})</h4>
      ${llmCalls.map((c) => `
        <details class="llm-item">
          <summary>${esc(c.model)} · ${esc(c.location_raw || "")} → ${c.is_null ? "aucun résultat" : esc(c.parsed_city || "?")} · ${c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—"}</summary>
          <pre class="llm-detail">PROMPT:\n${esc(c.prompt || "")}\n\nRÉPONSE:\n${esc(c.response || "")}</pre>
        </details>`).join("") || `<p class="muted-note">Aucun appel LLM pour ce groupe.</p>`}
    </details>

    <div class="modal-error" id="m-error" role="alert"></div>
    <div class="modal-actions">
      <button class="btn btn-mini" data-close>Annuler</button>
      <button class="btn btn-primary-sm" id="m-save">Enregistrer</button>
    </div>`;

  const modal = openModal(`${band.name} — #${band.ma_id}`, body, "modal-wide");

  const conflictBody = modal.querySelector("#m-conflict-body");
  conflictBody?.addEventListener("click", async (e) => {
    const btn = e.target.closest("[data-conflict-action]");
    if (!btn) return;
    btn.disabled = true;
    try {
      await api(`/admin/api/bands/${maId}/resolve-conflict`, {
        method: "POST",
        body: JSON.stringify({ field: btn.dataset.field, action: btn.dataset.conflictAction }),
      });
      closeModal(modal);
      openBandModal(maId);
    } catch (err) { alert(err.message); btn.disabled = false; }
  });

  modal.querySelector("#m-save").addEventListener("click", async () => {
    const errEl = modal.querySelector("#m-error");
    errEl.textContent = "";
    const val = (k) => modal.querySelector(`#m-${k}`).value.trim();
    const num = (k) => (val(k) === "" ? null : Number(val(k)));
    const payload = {
      name: val("name") || null, country: val("country") || null,
      status: val("status") || null, genre: val("genre") || null,
      location_text: val("location_text") || null, themes: val("themes") || null,
      formed_year: num("formed_year"), lat: num("lat"), lon: num("lon"),
    };
    try {
      await api(`/admin/api/bands/${maId}`, { method: "PATCH", body: JSON.stringify(payload) });
      closeModal(modal);
      if (state.view === "bands") loadBands();
      else if (state.view === "locations") loadLocations();
    } catch (e) {
      errEl.textContent = e.message;
    }
  });
}

// ==================================================================
// JOURNAL — « que s'est-il passé ? »
// ==================================================================
async function renderActivity(token = renderToken) {
  const s = state.activity;
  content().innerHTML = `
    <div class="view-head">
      <h1>Journal</h1>
      <span id="a-refresh" class="refresh-note" aria-live="polite">Actualisation auto toutes les 15 s</span>
    </div>
    <div class="chips" role="group" aria-label="Filtrer le journal">
      <button class="chip ${s.type === "" ? "on" : ""}" data-atype="">Tout</button>
      <button class="chip ${s.type === "run" ? "on" : ""}" data-atype="run">Traitements</button>
      <button class="chip ${s.type === "admin_action" ? "on" : ""}" data-atype="admin_action">Actions admin</button>
      <span class="chip-sep"></span>
      <button class="chip ${s.status === "error" ? "on" : ""}" data-astatus="error">Erreurs seules</button>
    </div>
    <div id="a-table" class="table-wrap"><div class="loading">Chargement…</div></div>
    <div class="pager" id="a-pager"></div>
  `;

  document.querySelector(".chips").addEventListener("click", (e) => {
    const t = e.target.closest("[data-atype]");
    const st = e.target.closest("[data-astatus]");
    if (t) { s.type = t.dataset.atype; s.page = 1; renderActivity(); }
    else if (st) { s.status = s.status === st.dataset.astatus ? "" : st.dataset.astatus; s.page = 1; renderActivity(); }
  });

  await loadActivity();
  if (isStale(token)) return;
  clearTimer();
  state.timer = setInterval(async () => {
    await loadActivity();
    const el = document.getElementById("a-refresh");
    if (el) el.textContent = `Actualisé à ${new Date().toLocaleTimeString("fr-FR")}`;
  }, 15000);
}

function activitySummary(row) {
  const sum = row?.summary || {};
  if (row?.type === "run") {
    const parts = [];
    if (sum.bands_seen != null) parts.push(`${fmtNum(sum.bands_seen)} vus`);
    if (sum.bands_new) parts.push(`${fmtNum(sum.bands_new)} nouveaux`);
    if (sum.bands_updated) parts.push(`${fmtNum(sum.bands_updated)} MAJ`);
    if (sum.bands_enriched) parts.push(`${fmtNum(sum.bands_enriched)} enrichis`);
    if (sum.error) parts.push(`erreur : ${sum.error}`);
    return parts.join(" · ") || "—";
  }
  return `${esc(sum.target_table || "")} #${sum.target_id ?? ""}`;
}

async function loadActivity() {
  const s = state.activity;
  const params = new URLSearchParams({ page: s.page, pageSize: s.pageSize });
  if (s.type) params.set("type", s.type);
  if (s.status) params.set("status", s.status);
  try {
    const r = await api(`/admin/api/activity?${params}`);
    s.total = r.total;
    const tableEl = document.getElementById("a-table");
    if (!tableEl) return;
    tableEl.innerHTML = `
      <table>
        <thead><tr><th>Date</th><th>Type</th><th>Détail</th><th>Statut</th><th>Auteur</th><th>Résumé</th></tr></thead>
        <tbody>
          ${r.items.map((x, i) => `
            <tr class="clickable" data-idx="${i}" tabindex="0" role="button">
              <td class="nowrap">${fmtDate(x.ts)}</td>
              <td><span class="badge muted">${x.type === "run" ? "Traitement" : "Admin"}</span></td>
              <td>${esc(x.subtype)}</td>
              <td>${statusBadge(x.status)}</td>
              <td>${esc(x.actor || "—")}</td>
              <td class="truncate">${activitySummary(x)}</td>
            </tr>`).join("") || `<tr><td colspan="6" class="empty">Aucune activité</td></tr>`}
        </tbody>
      </table>`;

    document.getElementById("a-table").querySelectorAll("tr[data-idx]").forEach((tr) => {
      const row = r.items[Number(tr.dataset.idx)];
      const open = () => openActivityModal(row);
      tr.addEventListener("click", open);
      tr.addEventListener("keydown", (e) => {
        if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); }
      });
    });

    renderPager("a-pager", s, loadActivity);
  } catch (e) {
    document.getElementById("a-table").innerHTML = errorBox(e);
  }
}

async function openActivityModal(row) {
  const sum = row?.summary || {};
  const isRun = row?.type === "run";
  const body = isRun
    ? `<div class="muted-note">
         ${statusBadge(row.status)} · démarré ${fmtDate(row.ts)} · terminé ${fmtDate(row.finished_at)}
         ${sum.countries ? ` · pays : ${esc((sum.countries || []).join(", "))}` : ""}
       </div>
       <p>${activitySummary(row)}</p>
       ${row.status === "running" ? `<button class="btn btn-mini btn-danger" id="am-cancel">Arrêter ce traitement</button>` : ""}
       <h4 class="mt">Journal détaillé</h4>
       <div id="act-logs" class="log-stream">Chargement…</div>`
    : `<div class="muted-note">${fmtDate(row.ts)} · ${esc(row.actor || "")} · ${esc(row.subtype || "")}</div>
       <p>Cible : ${esc(sum.target_table || "")} #${sum.target_id ?? ""}</p>
       <div class="grid grid-2">
         <div><h4>Avant</h4><pre class="json">${esc(JSON.stringify(sum.before, null, 2))}</pre></div>
         <div><h4>Après</h4><pre class="json">${esc(JSON.stringify(sum.after, null, 2))}</pre></div>
       </div>`;

  const modal = openModal(
    isRun ? `Traitement #${row.id} — ${esc(row.subtype || "")}` : `Action admin #${row.id}`,
    body, "modal-wide");

  modal.querySelector("#am-cancel")?.addEventListener("click", async (e) => {
    if (!confirm(`Demander l'arrêt du traitement #${row.id} ?`)) return;
    e.target.disabled = true;
    try { await api(`/admin/api/crawl-runs/${row.id}/cancel`, { method: "POST", body: "{}" }); closeModal(modal); loadActivity(); }
    catch (err) { alert(err.message); }
  });

  if (isRun) {
    try {
      const logs = await api(`/admin/api/logs?run_id=${row.id}&pageSize=200`);
      const el = modal.querySelector("#act-logs");
      if (el) {
        el.innerHTML = logs.items.map((x) => `
          <div class="log-line">
            <span class="ts">${fmtDate(x.created_at)}</span>
            <span class="lvl lvl-${esc(x.level)}">${esc(x.level)}</span>
            <span class="msg">${esc(x.message)}</span>
          </div>`).join("") || emptyRow("Aucun log pour ce traitement");
      }
    } catch (e) {
      const el = modal.querySelector("#act-logs");
      if (el) el.innerHTML = errorBox(e);
    }
  }
}

// ==================================================================
// LLM & COÛTS
// ==================================================================
async function renderLLM() {
  content().innerHTML = `
    <div class="view-head"><h1>LLM &amp; coûts</h1><span id="llm-status" class="refresh-note"></span></div>
    <div id="llm-summary" class="health-strip"><div class="loading">Chargement…</div></div>
    <p class="muted-note">
      Chaque appel Groq est mis en cache (modèle + pays + lieu) et rattaché au groupe
      qui l'a déclenché. Déplier une ligne montre le prompt exact et la réponse brute.
    </p>
    <div class="search-bar">
      <input type="search" id="llm-q" placeholder="Filtrer par lieu ou ville…" aria-label="Filtrer les appels LLM">
      <select id="llm-model" aria-label="Modèle">
        <option value="">Tous les modèles</option>
        <option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile</option>
        <option value="llama-3.1-8b-instant">llama-3.1-8b-instant</option>
      </select>
      <label class="inline-check"><input type="checkbox" id="llm-null"> Sans résultat uniquement</label>
      <button class="btn btn-primary-sm" id="llm-apply">Filtrer</button>
    </div>
    <div id="llm-list" class="table-wrap"><div class="loading">Chargement…</div></div>
    <div class="pager" id="llm-pager"></div>
  `;

  document.getElementById("llm-apply").addEventListener("click", () => { state.llm.page = 1; loadLLM(); });
  document.getElementById("llm-q").addEventListener("keydown", (e) => {
    if (e.key === "Enter") { state.llm.page = 1; loadLLM(); }
  });
  document.getElementById("llm-list").addEventListener("click", (e) => {
    const band = e.target.closest("[data-open-band]");
    if (band) openBandModal(Number(band.dataset.openBand));
  });

  loadLLMSummary();
  await loadLLM();
}

async function loadLLMSummary() {
  try {
    const geo = await api("/admin/api/geocoding");
    const c = geo.llm_cache || {};
    const models = geo.llm_models || [];
    document.getElementById("llm-summary").innerHTML = [
      healthCard("Coût total", `$${Number(c.total_cost_usd || 0).toFixed(4)}`, "cumul Groq"),
      healthCard("Appels", fmtNum(c.n), `${fmtNum(c.n_null)} sans résultat`),
      healthCard("Cache géocodage", fmtNum(geo.cache_size), "lieux déjà résolus"),
      ...models.slice(0, 2).map((m) =>
        healthCard(m.model, fmtNum(m.n), `$${Number(m.cost || 0).toFixed(4)}`)),
    ].join("");
  } catch (e) {
    document.getElementById("llm-summary").innerHTML = errorBox(e);
  }
}

async function loadLLM() {
  const q = document.getElementById("llm-q")?.value.trim() || "";
  const model = document.getElementById("llm-model")?.value || "";
  const onlyNull = document.getElementById("llm-null")?.checked ? "1" : "";
  const page = state.llm.page || 1;
  const params = new URLSearchParams({ page: String(page), pageSize: "50" });
  if (q) params.set("q", q);
  if (model) params.set("model", model);
  if (onlyNull) params.set("only_null", "1");

  try {
    const r = await api(`/admin/api/llm?${params}`);
    document.getElementById("llm-status").textContent = `${fmtNum(r.total)} appel(s)`;

    document.getElementById("llm-list").innerHTML = r.items.length ? `
      <table>
        <thead><tr><th>Date</th><th>Groupe</th><th>Modèle</th><th>Lieu</th><th>Résultat</th><th>Tokens</th><th>Coût</th></tr></thead>
        <tbody>
          ${r.items.map((c) => `
            <tr>
              <td class="nowrap">${fmtDate(c.created_at)}</td>
              <td>${c.ma_id ? `<button class="link-btn" data-open-band="${c.ma_id}">${esc(c.band_name || "#" + c.ma_id)}</button>` : "—"}</td>
              <td class="sub">${esc(c.model)}</td>
              <td>${esc(c.location_raw || "—")}${c.country ? ` <span class="sub">[${esc(c.country)}]</span>` : ""}</td>
              <td>${c.is_null ? '<span class="badge warn">aucun</span>' : `${esc(c.parsed_city || "?")} <span class="sub">(${esc(c.parsed_country || "?")})</span>`}</td>
              <td class="mono">${(c.tokens_in || 0)}+${(c.tokens_out || 0)}</td>
              <td class="mono">${c.cost_usd != null ? `$${Number(c.cost_usd).toFixed(6)}` : "—"}</td>
            </tr>
            <tr class="detail-row"><td colspan="7">
              <details><summary>Prompt et réponse</summary>
                <pre class="llm-detail">PROMPT:\n${esc(c.prompt || "")}\n\nRÉPONSE:\n${esc(c.response || "")}</pre>
              </details>
            </td></tr>`).join("")}
        </tbody>
      </table>` : emptyRow("Aucun appel LLM");

    const s = { page, pageSize: 50, total: r.total };
    renderPager("llm-pager", s, () => { state.llm.page = s.page; loadLLM(); });
  } catch (e) {
    document.getElementById("llm-list").innerHTML = errorBox(e);
  }
}

// ------------------------------------------------------------------
checkSession();
