/* Spark Control front-end. Talks to Python through window.pywebview.api. */
"use strict";

const $ = (id) => document.getElementById(id);
const api = () => window.pywebview.api;

const state = {
  boot: null,
  status: null,
  liveLog: [],          // {i,t,src,text}
  lastLogIndex: 0,
  logSource: "live",
  logLevel: "all",
  fileLines: [],        // lines of currently selected file log
  afVideosScanned: 0,   // parsed from the finder's "[  N]" output lines
  afWasRunning: false,  // to detect the running→stopped transition
  cgLastDuration: 0,    // duration (days) of the current grab, for the code card
  cgNoteDismissed: false, // ad-permissions note dismissed this session
  refTarget: "spark-reference-ads", // which set an upload goes to
  refFiles: {},         // files per bucket, from the last load
  previewEnabled: false, // live phone-screen preview (persisted)
  previewRate: 2,        // poll-fallback pacing (seconds); stream mode ignores it
  previewLastFrameAt: 0,
  wdaWatch: false,
  startingEverything: false,
  wdaBuildStart: 0,     // client epoch(ms) when the build overlay first appeared
  setupFixRunning: null, // id of the setup fix currently streaming, if any
  adSlides: [],         // signed slide-URL arrays, indexed by Ads Found row
  adsSelected: new Set(), // ids selected for bulk delete (current view only)
  proxies: [],          // saved proxy nodes
  proxySelectedId: null, // locally-marked "current" node
  proxyEditId: null,    // id being edited in the dialog, null = adding
  proxyRouting: "tiktok",
  proxyLastPath: null,  // last generated config path (for Reveal in Finder)
};

const MAX_CLIENT_LINES = 8000;
// Client-side safety cap. The backend already fails a stuck build at ~3 min;
// this is a hair longer so the backend's nicer message usually wins, but the
// overlay still dismisses even if status polling stalls.
const WDA_BUILD_CAP_MS = 195000;
// Tooltips shown when a device-dependent action is disabled for lack of a phone.
const TIP_WDA_NO_PHONE = "iPhone not connected — xcodebuild has no destination, "
  + "so the build fails (code 70). Plug it in, unlock it, and tap Trust.";
const TIP_TOOL_NO_PHONE = "iPhone not connected — this tool drives the phone. "
  + "Plug it in, unlock it, and tap Trust.";

/* ---------------- helpers ---------------- */

function toast(msg, isErr) {
  const t = $("toast");
  t.textContent = msg;
  t.className = "toast" + (isErr ? " err" : "");
  t.hidden = false;
  clearTimeout(t._timer);
  t._timer = setTimeout(() => { t.hidden = true; }, 3500);
}

function levelOf(text) {
  if (/error|exception|traceback|failed|fatal|\[upload failed/i.test(text)) return "error";
  if (/warn|skipped|retry|timeout/i.test(text)) return "warn";
  return "info";
}

function fmtElapsed(startEpoch) {
  const s = Math.max(0, Math.floor(Date.now() / 1000 - startEpoch));
  const m = Math.floor(s / 60), r = s % 60;
  return String(m).padStart(2, "0") + ":" + String(r).padStart(2, "0");
}

function fmtDate(iso) {
  if (!iso) return "";
  const d = new Date(iso);
  return isNaN(d) ? iso : d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}

// Like fmtDate but with the year — for dates that can be far in the future
// (e.g. a profile expiry), where an absent year reads as a past date.
function fmtDateYear(iso) {
  if (!iso) return "";
  const d = new Date(iso);
  return isNaN(d) ? iso : d.toLocaleString([], { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}

function esc(s) {
  const div = document.createElement("div");
  div.textContent = s == null ? "" : String(s);
  return div.innerHTML;
}

function setDot(el, ok, busy) {
  el.className = "dot " + (busy ? "busy" : ok ? "ok" : "bad");
}

// Disable a button and, only when the disabling reason is `tip`, attach it as a
// hover tooltip. Passing tip="" clears any stale tooltip (e.g. disabled while
// a tool runs, not for lack of a phone).
function gate(id, disabled, tip) {
  const b = $(id);
  if (!b) return;
  b.disabled = disabled;
  b.title = (disabled && tip) ? tip : "";
}

/* ---------------- diagnostics ----------------
   Reports what (if anything) is covering the window and eating clicks, and
   confirms the current stylesheet is loaded. Prints to the browser console
   AND to the launching terminal via the Python bridge. Re-run any time from
   the devtools console with `diagnose()`. */
const EXPECTED_BG = "#0a0e14";  // --bg from the teal restyle; proves fresh CSS

function describe(el) {
  if (!el) return "(none)";
  const cls = (el.className && typeof el.className === "string")
    ? "." + el.className.trim().replace(/\s+/g, ".") : "";
  return "<" + el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + cls + ">";
}

async function diagnose(reason) {
  const out = ["[diag" + (reason ? ":" + reason : "") + "]"];

  const bg = getComputedStyle(document.documentElement)
    .getPropertyValue("--bg").trim().toLowerCase();
  out.push("  --bg=" + (bg || "(unset)") + " expected=" + EXPECTED_BG +
    " -> " + (bg === EXPECTED_BG ? "fresh contrast CSS loaded" : "STALE/UNLOADED CSS"));

  // What actually receives a click at the window centre?
  const cx = Math.floor(innerWidth / 2), cy = Math.floor(innerHeight / 2);
  const hit = document.elementFromPoint(cx, cy);
  if (hit) {
    const cs = getComputedStyle(hit);
    out.push("  centre hit-test: " + describe(hit) +
      " display=" + cs.display + " pointer-events=" + cs.pointerEvents +
      " z-index=" + cs.zIndex);
  }

  // Any full-viewport, clickable, painted element sitting over the app?
  const blockers = [];
  for (const el of document.body.querySelectorAll("*")) {
    const cs = getComputedStyle(el);
    if ((cs.position === "fixed" || cs.position === "absolute") &&
        cs.display !== "none" && cs.visibility !== "hidden" &&
        cs.pointerEvents !== "none" && parseFloat(cs.opacity || "1") > 0) {
      const r = el.getBoundingClientRect();
      if (r.width >= innerWidth * 0.9 && r.height >= innerHeight * 0.9) {
        blockers.push(describe(el) + " display=" + cs.display +
          " z-index=" + cs.zIndex + " hidden-attr=" + el.hasAttribute("hidden"));
      }
    }
  }
  out.push(blockers.length
    ? "  FULL-VIEWPORT CLICK BLOCKERS: " + blockers.join("  |  ")
    : "  no full-viewport click blockers");

  const report = out.join("\n");
  console.log(report);
  try { await api().debug_log(report); } catch (e) { /* bridge not ready */ }
  return report;
}
window.diagnose = diagnose;

/* ---------------- navigation ---------------- */

const viewLoaders = {
  flows: loadFlows,
  adfinder: loadAdFinder,
  ads: loadAds,
  codes: loadCodes,
  poster: loadPoster,
  settings: loadSettings,
  references: loadReferences,
  logs: refreshLogView,
  signing: loadSigning,
  setup: loadSetup,
  proxy: loadProxy,
};

function showView(name) {
  document.querySelectorAll(".nav-item").forEach(b =>
    b.classList.toggle("active", b.dataset.view === name));
  document.querySelectorAll(".view").forEach(v =>
    v.classList.toggle("active", v.id === "view-" + name));
  if (viewLoaders[name]) viewLoaders[name]();
  updatePreviewRunState();   // preview only runs on Ad Finder / Spark Codes
}

$("nav").addEventListener("click", (e) => {
  const btn = e.target.closest(".nav-item");
  if (btn) showView(btn.dataset.view);
});

/* ---------------- bootstrap ---------------- */

async function boot() {
  const b = await api().get_bootstrap();
  state.boot = b;
  $("versionLabel").textContent = "v" + b.version;
  $("setVersion").textContent = "v" + b.version;
  $("setSparkDir").value = b.settings.spark_dir;
  $("setWdaDir").value = b.settings.wda_dir;
  $("setUdid").value = b.settings.udid;
  $("grabberMissing").hidden = b.code_grabber_exists;

  // Auto-open Setup when anything required is missing; otherwise stay on Dashboard.
  try {
    const setup = await refreshSetup();
    if (setup && !setup.all_required_ok) showView("setup");
  } catch { /* checks failed to run; leave on Dashboard */ }

  pollStatus();
  pollLiveLog();
  loadCounts();
  setInterval(pollStatus, 3000);
  setInterval(pollLiveLog, 1000);
  setInterval(loadCounts, 60000);
  setInterval(tickElapsed, 1000);
  diagnose("boot");
  checkForUpdate(true);   // silent — only surfaces the banner if there's a newer build
  loadPreviewPrefs();     // restore the saved preview toggle/rate
}

/* ---------------- auto-update ---------------- */

async function checkForUpdate(silent) {
  let r;
  try { r = await api().check_for_update(); } catch { return; }
  if (!r || !r.ok) {
    if (!silent) { $("updateMsg").textContent = (r && r.error) || "Couldn't check"; $("updateMsg").className = "savemsg err"; }
    return;
  }
  if (r.available) {
    $("updateBannerText").textContent = `Version ${r.version} available`;
    $("updateBanner").hidden = false;
    $("updateBanner").classList.remove("err");
    $("btnUpdateNow").hidden = false;
    $("btnUpdateDismiss").hidden = false;
    if (!silent) { $("updateMsg").textContent = `Version ${r.version} available`; $("updateMsg").className = "savemsg"; }
  } else if (!silent) {
    $("updateMsg").textContent = `Up to date (v${r.current})`;
    $("updateMsg").className = "savemsg";
  }
}

function updateBannerError(msg) {
  const banner = $("updateBanner");
  banner.hidden = false;
  banner.classList.add("err");
  $("updateBannerText").textContent = "Update failed: " + msg;
  $("btnUpdateNow").hidden = true;
  $("btnUpdateDismiss").hidden = false;
}

async function startUpdate() {
  $("btnUpdateNow").disabled = true;
  const r = await api().apply_update();
  if (!r.ok) { $("btnUpdateNow").disabled = false; updateBannerError(r.error); return; }
  pollUpdateProgress();
}

function pollUpdateProgress() {
  const tick = async () => {
    let p;
    try { p = await api().get_update_progress(); } catch { return; }  // app may be quitting to relaunch
    if (!p || !p.ok) return;
    if (p.error) { $("btnUpdateNow").disabled = false; updateBannerError(p.error); return; }
    const label = {
      downloading: `Downloading update… ${p.pct || 0}%`,
      verifying: "Verifying…",
      installing: "Installing…",
      relaunching: "Relaunching…",
    }[p.phase] || "Updating…";
    $("updateBannerText").textContent = label;
    $("btnUpdateNow").hidden = true;
    $("btnUpdateDismiss").hidden = true;
    if (p.phase === "relaunching") return;   // app is about to quit and reopen
    setTimeout(tick, 500);
  };
  tick();
}

$("btnUpdateNow").onclick = startUpdate;
$("btnUpdateDismiss").onclick = () => { $("updateBanner").hidden = true; };
$("btnCheckUpdate").onclick = () => {
  $("updateMsg").textContent = "Checking…"; $("updateMsg").className = "savemsg";
  checkForUpdate(false);
};

/* ---------------- status polling ---------------- */

async function pollStatus() {
  let st;
  try { st = await api().get_status(); } catch { return; }
  if (!st || !st.ok) return;
  state.status = st;

  // dashboard dots
  setDot($("dotPhone"), st.phone.ok);
  $("detailPhone").textContent = st.phone.detail || "";
  setDot($("dotWda"), st.wda.ok, st.wda_state === "starting");
  $("detailWda").textContent = st.wda.ok ? "responding on :8100"
    : st.wda_state === "starting" ? "building…" : "not responding";
  setDot($("dotIproxy"), st.iproxy.ok);
  $("detailIproxy").textContent = st.iproxy.ok ? "running" : "not running";
  // Idle is the normal resting state — neutral grey, not red. Amber while running.
  $("dotTool").className = "dot" + (st.tool.running ? " busy" : "");
  $("detailTool").textContent = st.tool.running
    ? toolLabel(st.tool.name) + " running" : "idle";

  // WDA build progress / error — the inline overlay must ALWAYS dismiss:
  // on ready, on failed, and on the client-side timeout cap below.
  updateWdaOverlay(st);

  // A streaming setup fix just finished → re-run the checks to reflect it.
  if (st.setup && !st.setup.running && state.setupFixRunning) {
    const id = state.setupFixRunning;
    state.setupFixRunning = null;
    toast(`Setup step "${id}" finished (exit ${st.setup.exit})`, st.setup.exit !== 0);
    if ($("view-setup").classList.contains("active")) refreshSetup();
  }

  // topbar message
  const bar = $("topbar"), msg = $("topbarMsg");
  let alert = null;
  if (!state.boot.env_ok) alert = "spark.env missing — Supabase views disabled (see setup)";
  else if (st.wda_state === "failed" && st.wda_error) alert = st.wda_error;
  else if (st.tool.running && !st.phone.ok) alert = "Tool is running but the iPhone is not connected!";
  else if (st.tool.running && !st.wda.ok) alert = "Tool is running but WebDriverAgent is not responding!";
  bar.classList.toggle("alert", !!alert);
  msg.textContent = alert ? "⚠ " + alert
    : st.tool.running ? toolLabel(st.tool.name) + " running — " + fmtElapsed(st.tool.started_at) + " elapsed"
    : st.phone.ok && st.wda.ok ? "Phone ready."
    : "Idle.";

  // tool buttons — the tools drive the phone, so a start needs it connected
  const running = st.tool.running;
  const phoneOk = !!(st.phone && st.phone.ok);
  const toolTip = phoneOk ? "" : TIP_TOOL_NO_PHONE;
  gate("btnAfStart", running || !phoneOk, toolTip);
  $("btnAfStop").disabled = !(running && st.tool.name === "ad_finder");
  gate("btnCgStart", running || !phoneOk, toolTip);
  gate("btnCgDebug", running || !phoneOk, toolTip);
  $("btnCgStop").disabled = !(running && (st.tool.name === "code_grabber" || st.tool.name === "debug_screen"));
  // "Start everything" also builds WDA, which needs the device.
  gate("btnEverything", state.startingEverything || !phoneOk, phoneOk ? "" : TIP_WDA_NO_PHONE);

  // ad finder: stats, readiness banner, last-run recording
  updateAdFinder(st);
  // spark codes: readiness banner, ad-permissions note, console
  updateSparkCodes(st);
  // poster: readiness banner, run/stop state, progress, console
  updatePoster(st);
  // flows: stage run/stop states, readiness, console
  updateFlows(st);
  // live preview follows tool/WDA state
  updatePreviewRunState();

  // captured code
  if (st.tool.spark_code) {
    $("codeValue").textContent = st.tool.spark_code;
    $("codeMeta").textContent = state.cgLastDuration ? `Authorised for ${state.cgLastDuration} days` : "";
    $("codeResult").hidden = false;
  }
}

function toolLabel(name) {
  return { ad_finder: "Ad Finder", code_grabber: "Code Grabber", debug_screen: "Debug screen" }[name] || name || "";
}

function tickElapsed() {
  const st = state.status;
  if (st && st.tool.running && st.tool.name === "ad_finder" && st.tool.started_at) {
    $("afStatElapsed").textContent = fmtElapsed(st.tool.started_at);
  }
  if (state.previewLastFrameAt) updatePreviewTs();  // keep the "Ns ago" fresh
  tickWdaOverlay();
}

/* ---------------- ad finder ---------------- */

function loadAdFinder() { loadLastRun(); updateAdFinder(state.status); }

async function loadLastRun() {
  let r;
  try { r = await api().get_last_ad_run(); } catch { return; }
  const body = $("afLastRunBody");
  if (!r || !r.ok || !r.run) {
    body.innerHTML = '<span class="hint">The finder hasn\'t run yet.</span>';
    return;
  }
  const run = r.run;
  const when = fmtDateYear(new Date(run.at * 1000).toISOString());
  const mins = Math.floor((run.seconds || 0) / 60), secs = (run.seconds || 0) % 60;
  body.innerHTML = `<div class="lastrun-grid">
    <div><div class="lr-num">${esc(run.ads)}</div><div class="lr-lbl">ads found</div></div>
    <div><div class="lr-num">${esc(run.videos)}</div><div class="lr-lbl">videos scanned</div></div>
    <div><div class="lr-num">${mins}m ${secs}s</div><div class="lr-lbl">duration</div></div>
    <div><div class="lr-num small">${esc(when)}</div><div class="lr-lbl">last run</div></div>
  </div>`;
}

// Console shows once a run has produced output, and stays expanded afterwards.
function updateToolConsole(outId, emptyId, names) {
  const t = state.status && state.status.tool;
  const running = t && t.running && names.includes(t.name);
  const show = running || $(outId).childElementCount > 0;
  $(outId).hidden = !show;
  $(emptyId).hidden = show;
}
function updateAfConsole() { updateToolConsole("afOutput", "afEmpty", ["ad_finder"]); }
function updateCgConsole() { updateToolConsole("cgOutput", "cgEmpty", ["code_grabber", "debug_screen"]); }

function updateSparkCodes(st) {
  if (!st) { updateCgConsole(); return; }
  const banner = $("cgNotReady");
  if (!st.phone.ok) {
    banner.hidden = false;
    $("cgNotReadyText").textContent = "iPhone not connected — plug it in, unlock it, and tap Trust.";
    $("cgStartEverything").hidden = true;
  } else if (!st.wda.ok) {
    banner.hidden = false;
    $("cgNotReadyText").textContent = "WebDriverAgent isn't running yet.";
    $("cgStartEverything").hidden = false;
  } else {
    banner.hidden = true;
  }
  // ad-permissions note: hidden once marked done in Setup or dismissed this session
  const done = state.boot && state.boot.ad_settings_done;
  $("cgAdPerm").hidden = !!done || state.cgNoteDismissed;
  updateCgConsole();
}

function updateAdFinder(st) {
  if (!st) { updateAfConsole(); return; }
  const running = st.tool.running && st.tool.name === "ad_finder";

  // readiness banner in Run controls (only relevant to this tool)
  const banner = $("afNotReady");
  if (!st.phone.ok) {
    banner.hidden = false;
    $("afNotReadyText").textContent = "iPhone not connected — plug it in, unlock it, and tap Trust.";
    $("afStartEverything").hidden = true;
  } else if (!st.wda.ok) {
    banner.hidden = false;
    $("afNotReadyText").textContent = "WebDriverAgent isn't running yet.";
    $("afStartEverything").hidden = false;
  } else {
    banner.hidden = true;
  }

  // live stats strip (running) vs last-run card (idle)
  $("afStats").hidden = !running;
  $("afLastRun").hidden = running;
  if (running) {
    $("afStatAds").textContent = st.tool.ads_found || 0;
    $("afStatVideos").textContent = state.afVideosScanned || 0;
    $("afStatStatus").textContent = (st.phone.ok && st.wda.ok) ? "Scanning…" : "Waiting for phone";
    if (st.tool.started_at) $("afStatElapsed").textContent = fmtElapsed(st.tool.started_at);
  }

  // record the run when the finder transitions running → stopped
  if (state.afWasRunning && !running) {
    const started = st.tool.started_at;
    const seconds = started ? Math.max(0, Math.floor(Date.now() / 1000 - started)) : 0;
    api().record_ad_run({ ads: st.tool.ads_found || 0, seconds, videos: state.afVideosScanned || 0 })
      .then(() => loadLastRun());
  }
  state.afWasRunning = running;

  updateAfConsole();
}

// Runs every second, independent of status polling, so the build timer stays
// smooth and the ~3-minute cap fires even if get_status stops responding.
function tickWdaOverlay() {
  if (!state.wdaBuildStart) return;
  const elapsedMs = Date.now() - state.wdaBuildStart;
  if (elapsedMs > WDA_BUILD_CAP_MS) {
    state.wdaBuildStart = 0;
    for (const t of WDA_TARGETS) {
      const p = $(t.progress); if (p) p.hidden = true;
      const err = $(t.error);
      if (err) {
        err.textContent = "WebDriverAgent build timed out after ~3 minutes. "
          + "Check the build output in Logs; if it mentions signing, re-sign in Xcode.";
        err.hidden = false;
      }
      for (const id of t.btns) { const b = $(id); if (b) b.disabled = false; }
    }
    return;
  }
  const secs = Math.floor(elapsedMs / 1000);
  const mm = String(Math.floor(secs / 60)).padStart(2, "0");
  const ss = String(secs % 60).padStart(2, "0");
  for (const t of WDA_TARGETS) {
    const txt = $(t.text);
    if (txt) txt.textContent = `Building WebDriverAgent… ${mm}:${ss}`;
  }
}

/* ---------------- live log ---------------- */

async function pollLiveLog() {
  let res;
  try { res = await api().get_live_log(state.lastLogIndex); } catch { return; }
  if (!res || !res.ok || !res.lines.length) return;
  state.lastLogIndex = res.next;
  state.liveLog.push(...res.lines);
  if (state.liveLog.length > MAX_CLIENT_LINES)
    state.liveLog.splice(0, state.liveLog.length - MAX_CLIENT_LINES);
  const toolOnly = (l) => l.src !== "wda" && l.src !== "setup";
  const toolLines = res.lines.filter(toolOnly);
  appendLines($("afOutput"), toolLines);
  appendLines($("cgOutput"), toolLines);
  appendLines($("poOutput"), toolLines);
  appendLines($("flowOutput"), toolLines);
  updatePosterProgress(toolLines);
  // videos scanned = highest "[  N]" counter the finder has printed
  for (const l of toolLines) {
    const m = /^\s*\[\s*(\d+)\]/.exec(l.text || "");
    if (m) state.afVideosScanned = Math.max(state.afVideosScanned, +m[1]);
  }
  if (toolLines.length) updateAfConsole();
  if (state.logSource === "live") {
    appendLines($("logView"), res.lines.filter(matchesLogFilters), true);
    updateLogCount();
  }
}

function lineHtml(entry) {
  const text = typeof entry === "string" ? entry : entry.text;
  const lvl = levelOf(text);
  const cls = "logline " + lvl + (entry.src === "wda" ? " wda" : "");
  const tag = { info: "INF", warn: "WRN", error: "ERR" }[lvl];
  return `<span class="${cls}"><span class="tag">${tag}</span>${esc(text)}</span>`;
}

function appendLines(box, lines, respectAutoscroll) {
  if (!lines.length) return;
  const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 60;
  box.insertAdjacentHTML("beforeend", lines.map(lineHtml).join(""));
  while (box.childElementCount > 4000) box.removeChild(box.firstElementChild);
  const auto = respectAutoscroll ? $("logAutoscroll").checked : true;
  if (auto || atBottom) box.scrollTop = box.scrollHeight;
}

/* ---------------- logs view ---------------- */

function matchesLogFilters(entry) {
  const text = typeof entry === "string" ? entry : entry.text;
  if (state.logLevel !== "all" && levelOf(text) !== state.logLevel) return false;
  const q = $("logSearch").value.trim().toLowerCase();
  if (q && !text.toLowerCase().includes(q)) return false;
  return true;
}

async function refreshLogView() {
  const box = $("logView");
  box.innerHTML = "";
  let lines;
  if (state.logSource === "live") {
    lines = state.liveLog.filter(matchesLogFilters);
  } else {
    const res = await api().read_log_file(state.logSource);
    state.fileLines = (res && res.ok) ? res.lines : [];
    lines = state.fileLines.filter(matchesLogFilters);
  }
  appendLines(box, lines, true);
  updateLogCount();
}

function updateLogCount() {
  $("logCount").textContent = $("logView").childElementCount + " lines";
}

$("logSource").addEventListener("click", (e) => {
  const b = e.target.closest(".seg-btn"); if (!b) return;
  state.logSource = b.dataset.src;
  $("logSource").querySelectorAll(".seg-btn").forEach(x => x.classList.toggle("active", x === b));
  refreshLogView();
});
$("logLevel").addEventListener("click", (e) => {
  const b = e.target.closest(".seg-btn"); if (!b) return;
  state.logLevel = b.dataset.level;
  $("logLevel").querySelectorAll(".seg-btn").forEach(x => x.classList.toggle("active", x === b));
  refreshLogView();
});
$("logSearch").addEventListener("input", () => refreshLogView());
$("btnLogClear").onclick = async () => {
  if (state.logSource === "live") {
    await api().clear_live_log();
    state.liveLog = [];
    $("afOutput").innerHTML = "";
    $("cgOutput").innerHTML = "";
  }
  refreshLogView();
};

/* ---------------- dashboard actions ---------------- */

async function loadCounts() {
  if (!state.boot || !state.boot.env_ok) return;
  const res = await api().get_dashboard_counts();
  if (res && res.ok) {
    $("statAds").textContent = res.ads_today;
    $("statCodes").textContent = res.codes_today;
  }
}
$("btnRefreshCounts").onclick = loadCounts;

$("btnIproxy").onclick = async () => {
  const r = await api().start_iproxy();
  toast(r.ok ? (r.note || "iproxy started") : r.error, !r.ok);
  pollStatus();
};

// The WDA build state is global; render it into every inline progress card
// (Dashboard + Signing). `link:true` cards turn a signing failure into a link
// to the Signing section instead of dumping the message inline.
const WDA_TARGETS = [
  { progress: "wdaProgress", text: "wdaProgressText", error: "wdaError", btns: ["btnWda"], link: true },
  { progress: "signProgress", text: "signProgressText", error: "signError", btns: ["btnSignResign"], link: false },
];

function updateWdaOverlay(st) {
  const building = st.wda_state === "starting";
  const phoneOk = !!(st.phone && st.phone.ok);
  if (building && !state.wdaBuildStart) state.wdaBuildStart = Date.now();
  if (!building) state.wdaBuildStart = 0;

  for (const t of WDA_TARGETS) {
    const progress = $(t.progress), err = $(t.error);
    if (!progress || !err) continue;

    if (building) {
      progress.hidden = false;
      err.hidden = true;
    } else {
      progress.hidden = true;
      if (st.wda_state === "failed" && st.wda_error) {
        if (t.link && st.wda_error_kind === "signing") {
          err.innerHTML = esc(st.wda_error) +
            ' <a href="#" class="err-link" data-goto="signing">Open Signing &rarr;</a>';
        } else {
          err.textContent = st.wda_error;
        }
        err.hidden = false;
      } else {
        err.hidden = true;
      }
    }
    // A WDA build needs the device as its xcodebuild destination.
    for (const id of t.btns) gate(id, building || !phoneOk, phoneOk ? "" : TIP_WDA_NO_PHONE);
  }

  if (building) tickWdaOverlay();  // set timer text now and enforce the cap
}

$("btnWda").onclick = async () => {
  const r = await api().start_wda();
  if (!r.ok) toast(r.error, true);
  else toast(r.note || "Building WebDriverAgent — watch Logs for output");
  pollStatus();
};

async function cancelWdaBuild(btnId) {
  const btn = $(btnId);
  if (btn) btn.disabled = true;
  state.wdaBuildStart = 0;
  for (const t of WDA_TARGETS) { const p = $(t.progress); if (p) p.hidden = true; }
  try {
    await api().stop_wda();
    toast("WebDriverAgent build cancelled");
  } catch {
    toast("Couldn't cancel the build", true);
  } finally {
    if (btn) btn.disabled = false;
  }
  pollStatus();
}
$("btnWdaCancel").onclick = () => cancelWdaBuild("btnWdaCancel");

$("btnEverything").onclick = async () => {
  if (state.startingEverything) return;
  state.startingEverything = true;
  const btn = $("btnEverything");
  btn.disabled = true;
  try {
    const p = await api().start_iproxy();
    if (!p.ok) { toast(p.error, true); return; }
    const w = await api().start_wda();
    if (!w.ok) { toast(w.error, true); return; }
    toast("iproxy up — waiting for WebDriverAgent…");
    const deadline = Date.now() + 185000;
    while (Date.now() < deadline) {
      await new Promise(r => setTimeout(r, 3000));
      const st = state.status;
      if (st && st.wda.ok) { toast("✅ Phone is ready — iproxy + WDA are up."); return; }
      if (st && st.wda_state === "failed") { toast(st.wda_error || "WDA failed", true); return; }
    }
    toast("Timed out waiting for WDA — check Logs.", true);
  } finally {
    state.startingEverything = false;
    btn.disabled = false;
  }
};

/* ---------------- ad finder controls ---------------- */

$("btnAfStart").onclick = async () => {
  const r = await api().start_ad_finder({
    target_ads: +$("afTarget").value || 0,
    max_videos: +$("afMaxVideos").value || 0,
    upload: $("afUpload").checked,
    smart_filter: $("afSmart").checked,
    niche_only: $("afNiche").checked,
  });
  if (!r.ok) { toast(r.error, true); return; }
  toast("Ad Finder started");
  state.afVideosScanned = 0;
  $("afOutput").innerHTML = "";
  $("afStats").hidden = false;   // show the strip immediately
  $("afLastRun").hidden = true;
  updateAfConsole();
  pollStatus();
};

$("afStartEverything").onclick = () => $("btnEverything").click();
$("btnAfStop").onclick = async () => {
  await api().stop_tool();
  toast("Ad Finder stopped");
  pollStatus();
};

/* ---------------- spark codes ---------------- */

function grabberOpts(debug) {
  return {
    duration: +$("cgDuration").value,
    from_profile: $("cgFromProfile").checked,
    upload: $("cgUpload").checked,
    wait_for_post: +$("cgWait").value || 0,
    note: $("cgNote").value,
    debug_screen: !!debug,
  };
}

$("btnCgStart").onclick = async () => {
  $("codeResult").hidden = true;
  state.cgLastDuration = +$("cgDuration").value;
  const r = await api().start_code_grabber(grabberOpts(false));
  if (!r.ok) { toast(r.error, true); return; }
  toast("Code grabber started");
  $("cgOutput").innerHTML = "";
  updateCgConsole();
  pollStatus();
};
$("btnCgDebug").onclick = async () => {
  const r = await api().start_code_grabber(grabberOpts(true));
  if (!r.ok) { toast(r.error, true); return; }
  toast("Debug screen running — OCR dump below");
  $("cgOutput").innerHTML = "";
  updateCgConsole();
  pollStatus();
};
$("btnCgStop").onclick = async () => { await api().stop_tool(); pollStatus(); };
$("cgStartEverything").onclick = () => $("btnEverything").click();
$("cgAdPermDismiss").onclick = () => { state.cgNoteDismissed = true; $("cgAdPerm").hidden = true; };

$("btnCopyCode").onclick = async () => {
  await api().copy_text($("codeValue").textContent);
  toast("Code copied to clipboard");
};

async function loadCodes() {
  updateSparkCodes(state.status);
  if (!state.boot.env_ok) return;
  const res = await api().get_codes();
  const tbody = $("codesTable").querySelector("tbody");
  if (!res.ok) { toast(res.error, true); return; }
  tbody.innerHTML = res.codes.map(c => `
    <tr>
      <td class="mono">${esc(c.code)}</td>
      <td>${esc(c.duration_days)}</td>
      <td>${esc(fmtDate(c.captured_at))}</td>
      <td>${esc(c.note || "")}</td>
      <td><label class="toggle"><input type="checkbox" data-id="${esc(c.id)}" ${c.used ? "checked" : ""}><span></span></label></td>
    </tr>`).join("");
  $("codesEmpty").hidden = res.codes.length > 0;
}
$("btnCodesRefresh").onclick = loadCodes;
$("codesTable").addEventListener("change", async (e) => {
  const cb = e.target;
  if (cb.type !== "checkbox") return;
  const r = await api().set_code_used(cb.dataset.id, cb.checked);
  if (!r.ok) { cb.checked = !cb.checked; toast(r.error, true); }
});

/* ---------------- poster ---------------- */

function updatePoConsole() { updateToolConsole("poOutput", "poEmpty", ["poster"]); }

async function loadPoster() {
  updatePoster(state.status);
  let res;
  try { res = await api().get_poster_prefs(); } catch { return; }
  if (!res || !res.ok) return;
  $("posterMissing").hidden = !!res.poster_exists;
  const p = res.prefs || {};
  document.querySelectorAll("#poCount .seg-btn").forEach(b =>
    b.classList.toggle("active", String(b.dataset.count) === String(p.count || "1")));
  const useSidecars = !!p.use_sidecars;
  document.querySelectorAll("#poCaptionSrc .seg-btn").forEach(b =>
    b.classList.toggle("active", (b.dataset.src === "sidecars") === useSidecars));
  syncPosterCaptionMode(useSidecars);
  $("poCaption").value = p.caption || "";
  $("poDuration").value = String(p.duration || 60);
  $("poGrab").checked = !!p.grab_code;
  $("poPublish").checked = !!p.publish;
  syncPosterPublishWarn();
  applyPosterFolderStatus(p.folder || "", res.status || {});
  loadPosterPosts();
}

function syncPosterCaptionMode(useSidecars) {
  $("poSidecarNote").hidden = !useSidecars;
  $("poCaption").placeholder = useSidecars
    ? "Fallback caption for items without a sidecar"
    : "Caption applied to every item in this run";
}

function syncPosterPublishWarn() { $("poPublishWarn").hidden = !$("poPublish").checked; }

function applyPosterFolderStatus(folder, status) {
  $("poFolderPath").textContent = folder || "No folder chosen";
  $("poFolderPath").classList.toggle("muted", !folder);
  const valid = status && status.valid;
  $("poFolderInfo").hidden = !valid;
  $("poFolderInvalid").hidden = !(folder && !valid);
  if (folder && !valid)
    $("poFolderInvalid").textContent = (status && status.error) || "Folder not found or has no postable images.";
  if (valid) {
    const n = status.unposted || 0;
    $("poUnposted").textContent = n === 1 ? "1 to post" : n + " to post";
    const singles = (status.items || []).filter(i => i.kind === "photo").length;
    const carousels = (status.items || []).filter(i => i.kind === "carousel").length;
    const bits = [];
    if (singles) bits.push(singles + (singles === 1 ? " single" : " singles"));
    if (carousels) bits.push(carousels + (carousels === 1 ? " carousel" : " carousels"));
    $("poFolderBreakdown").textContent = bits.join(" · ");
  }
}

function posterOpts() {
  const count = document.querySelector("#poCount .seg-btn.active");
  const capSrc = document.querySelector("#poCaptionSrc .seg-btn.active");
  const path = $("poFolderPath").textContent;
  return {
    folder: path === "No folder chosen" ? "" : path,
    count: count ? count.dataset.count : "1",
    caption: $("poCaption").value,
    use_sidecars: !!(capSrc && capSrc.dataset.src === "sidecars"),
    duration: +$("poDuration").value,
    grab_code: $("poGrab").checked,
    publish: $("poPublish").checked,
  };
}

const savePosterPrefs = () => api().save_poster_prefs(posterOpts());

function updatePoster(st) {
  if (!st) { updatePoConsole(); return; }
  const running = st.tool && st.tool.running && st.tool.name === "poster";
  const banner = $("poNotReady");
  if (!st.phone.ok) {
    banner.hidden = false;
    $("poNotReadyText").textContent = "iPhone not connected — plug it in, unlock it, and tap Trust.";
    $("poStartEverything").hidden = true;
  } else if (!st.wda.ok) {
    banner.hidden = false;
    $("poNotReadyText").textContent = "WebDriverAgent isn't running yet.";
    $("poStartEverything").hidden = false;
  } else {
    banner.hidden = true;
  }
  const otherRunning = st.tool && st.tool.running && st.tool.name !== "poster";
  $("btnPoStart").disabled = running || otherRunning || !st.phone.ok || !st.wda.ok;
  $("btnPoStop").disabled = !running;
  $("poProgress").hidden = !running;
  if (state.poWasRunning && !running) { loadPosterPosts(); $("poProgress").hidden = true; }
  state.poWasRunning = running;
  updatePoConsole();
}

function updatePosterProgress(lines) {
  for (const l of lines) {
    const m = /=====\s*\[(\d+)\/(\d+)\]\s*(\w+):\s*([^(]+?)\s*\(/.exec(l.text || "");
    if (m) $("poProgress").textContent = `posting ${m[1]} of ${m[2]} — ${m[3]}: ${m[4].trim()}`;
    else if (/\[batch\] done\./.test(l.text || "")) $("poProgress").textContent = "batch done";
  }
}

async function loadPosterPosts() {
  let res;
  try { res = await api().get_poster_posts(40); } catch { return; }
  if (!res || !res.ok) return;
  const tbody = $("poPostsTable").querySelector("tbody");
  tbody.innerHTML = (res.posts || []).map(p => {
    const code = p.spark_code
      ? `<span class="mono">${esc(p.spark_code.slice(0, 14))}…</span> <button class="btn tiny po-copy" data-code="${esc(p.spark_code)}">Copy</button>`
      : "<span class='hint'>—</span>";
    return `<tr>
      <td class="hint">${esc(fmtDate(p.timestamp))}</td>
      <td>${esc(p.item)}${p.kind === "carousel" ? " <span class='poster-badge tiny'>carousel</span>" : ""}</td>
      <td class="po-cap">${esc(p.caption || "")}</td>
      <td>${code}</td>
      <td>${posterStatusChip(p.published)}</td>
    </tr>`;
  }).join("");
  $("poPostsEmpty").hidden = (res.posts || []).length > 0;
}

function posterStatusChip(published) {
  const v = String(published);
  if (v === "True") return `<span class="chip good">published</span>`;
  if (v === "False") return `<span class="chip">reviewed</span>`;
  if (v === "unknown") return `<span class="chip warn">unknown</span>`;
  return `<span class="chip">${esc(v || "—")}</span>`;
}

$("btnPoPick").onclick = async () => {
  const r = await api().pick_poster_folder();
  if (!r || !r.ok) return;
  if (r.folder) applyPosterFolderStatus(r.folder, r.status || {});
};

document.querySelectorAll("#poCount .seg-btn").forEach(b => b.onclick = () => {
  document.querySelectorAll("#poCount .seg-btn").forEach(x => x.classList.toggle("active", x === b));
  savePosterPrefs();
});
document.querySelectorAll("#poCaptionSrc .seg-btn").forEach(b => b.onclick = () => {
  document.querySelectorAll("#poCaptionSrc .seg-btn").forEach(x => x.classList.toggle("active", x === b));
  syncPosterCaptionMode(b.dataset.src === "sidecars");
  savePosterPrefs();
});
$("poCaption").addEventListener("change", savePosterPrefs);
$("poDuration").addEventListener("change", savePosterPrefs);
$("poGrab").addEventListener("change", savePosterPrefs);

// The publish toggle is the one control that writes publicly — confirm before ON.
$("poPublish").addEventListener("change", (e) => {
  if (e.target.checked) {
    const ok = confirm("Turn on real publishing?\n\nRuns with this ON post publicly to your TikTok account. Leave it off to stop at the Post screen for review.");
    if (!ok) e.target.checked = false;
  }
  syncPosterPublishWarn();
  savePosterPrefs();
});

$("poStartEverything").onclick = () => $("btnEverything").click();
$("btnPoRefresh").onclick = loadPosterPosts;
$("poPostsTable").addEventListener("click", async (e) => {
  const btn = e.target.closest(".po-copy");
  if (!btn) return;
  await api().copy_text(btn.dataset.code);
  toast("Spark code copied");
});

$("btnPoStart").onclick = async () => {
  const opts = posterOpts();
  if (!opts.folder) { toast("Pick a folder first", true); return; }
  if (opts.publish) {
    const ok = confirm(`Publish for real to your TikTok account?\n\nThis posts ${opts.count === "all" ? "every" : opts.count} item from the folder publicly.`);
    if (!ok) return;
  }
  await savePosterPrefs();
  const r = await api().start_poster(opts);
  if (!r.ok) { toast(r.error, true); return; }
  toast(opts.publish ? "Posting — publishing ON" : "Posting — dry run (stops at Post screen)");
  $("poOutput").innerHTML = "";
  $("poProgress").hidden = false;
  $("poProgress").textContent = "starting…";
  updatePoConsole();
  pollStatus();
};
$("btnPoStop").onclick = async () => { await api().stop_tool(); toast("Poster stopped"); pollStatus(); };

/* ---------------- ads found ---------------- */

// A carousel ad is ONE row: its slides live in screenshot_urls[] (ordered,
// first = main). Older rows have only screenshot_url. Returns the signed URLs.
function adSlides(a) {
  const urls = (Array.isArray(a.screenshot_urls) && a.screenshot_urls.length)
    ? a.screenshot_urls
    : (a.screenshot_url ? [a.screenshot_url] : []);
  return urls.filter(u => typeof u === "string" && u);
}

// Thumbnail cell: first slide + a "1/N" badge for carousels. The full slide list
// for row i is kept in state.adSlides[i] and read by the click handler/lightbox.
function adThumbCell(a, i) {
  const slides = state.adSlides[i];
  if (!slides.length) return "";
  const count = a.slide_count || slides.length;
  const badge = count > 1 ? `<span class="slide-badge">1/${esc(count)}</span>` : "";
  return `<div class="adthumb" data-ad="${i}">` +
    `<img class="thumb" loading="lazy" data-ad="${i}" data-slot="0" src="${esc(slides[0])}" onerror="thumbFallback(this)">` +
    badge + `</div>`;
}

// Guardrail: a slide path that 404s in the private bucket shouldn't show a
// broken image — advance the thumbnail to the next slide, or drop it if none load.
function thumbFallback(img) {
  const slides = state.adSlides[+img.dataset.ad] || [];
  const next = (+img.dataset.slot) + 1;
  if (next < slides.length) {
    img.dataset.slot = next;
    img.src = slides[next];
  } else {
    img.onerror = null;
    img.remove();
  }
}

async function loadAds() {
  if (!state.boot.env_ok) return;
  const res = await api().get_ads($("adsSuspicious").checked);
  if (!res.ok) { toast(res.error, true); return; }
  const ads = res.ads;
  state.adSlides = ads.map(adSlides);
  state.adsSelected.clear();   // selection is scoped to the current view/filter
  const tbody = $("adsTable").querySelector("tbody");
  tbody.innerHTML = ads.map((a, i) => `
    <tr>
      <td class="chkcol"><label class="chk row-chk"><input type="checkbox" data-id="${esc(a.id)}"><span></span></label></td>
      <td>${adThumbCell(a, i)}</td>
      <td>${esc(a.handle)}</td>
      <td>${esc(a.marker || "")}</td>
      <td>${esc(a.likes ?? "")}</td>
      <td>${a.suspicious ? '<span class="badge sus">SUS</span>' : '<span class="badge ok">ok</span>'}</td>
      <td>${esc(a.niche_keyword || "")}</td>
      <td>${esc(a.reason || "")}</td>
      <td>${esc(fmtDate(a.found_at))}</td>
    </tr>`).join("");
  $("adsEmpty").hidden = ads.length > 0;
  const sa = $("adsSelectAll"); if (sa) sa.checked = false;
  updateAdsSelUI();
}
$("btnAdsRefresh").onclick = loadAds;
$("adsSuspicious").addEventListener("change", loadAds);
$("adsTable").addEventListener("click", (e) => {
  const cell = e.target.closest(".adthumb");
  if (!cell) return;
  const slides = state.adSlides[+cell.dataset.ad] || [];
  const start = +(cell.querySelector(".thumb")?.dataset.slot || 0);
  if (slides.length > 1) openLightbox(slides, start);   // carousel → stepping gallery
  else if (slides.length === 1) showModal(slides[0]);   // single image → unchanged
});

/* ---- ads multi-select + bulk delete ---- */
function updateAdsSelUI() {
  const n = state.adsSelected.size;
  $("adsSelCount").hidden = n === 0;
  $("adsSelCount").textContent = `${n} selected`;
  $("btnAdsDelete").hidden = n === 0;
  $("btnAdsClear").hidden = n === 0;
  $("btnAdsDelete").textContent = `Delete selected (${n})`;
  const boxes = [...document.querySelectorAll("#adsTable .row-chk input")];
  const sa = $("adsSelectAll");
  if (sa) sa.checked = boxes.length > 0 && boxes.every(b => b.checked);
}

$("adsTable").addEventListener("change", (e) => {
  const cb = e.target;
  if (cb.id === "adsSelectAll") {
    document.querySelectorAll("#adsTable .row-chk input").forEach(b => {
      b.checked = cb.checked;
      if (cb.checked) state.adsSelected.add(b.dataset.id); else state.adsSelected.delete(b.dataset.id);
    });
    updateAdsSelUI();
  } else if (cb.matches(".row-chk input")) {
    if (cb.checked) state.adsSelected.add(cb.dataset.id); else state.adsSelected.delete(cb.dataset.id);
    updateAdsSelUI();
  } else return;
  console.log("[ads] selection:", [...state.adsSelected]);
});

$("btnAdsClear").onclick = () => {
  state.adsSelected.clear();
  document.querySelectorAll("#adsTable .row-chk input").forEach(b => { b.checked = false; });
  const sa = $("adsSelectAll"); if (sa) sa.checked = false;
  updateAdsSelUI();
};

$("btnAdsDelete").onclick = async () => {
  const ids = [...state.adsSelected];
  console.log("[ads] deleting:", ids);
  if (!ids.length) return;
  const msg = `Delete ${ids.length} ad${ids.length === 1 ? "" : "s"}? `
    + "This also removes their screenshots and can't be undone.";
  if (!confirm(msg)) return;                 // nothing deletes without confirming
  const btn = $("btnAdsDelete");
  btn.disabled = true;
  const r = await api().delete_ads(ids);     // batched; runs off the UI thread via the bridge
  btn.disabled = false;
  if (!r.ok) { toast(r.error, true); return; }
  toast(`Deleted ${r.deleted} ad${r.deleted === 1 ? "" : "s"}`);
  loadAds();                                 // refresh table (also clears selection)
};

// Single-image zoom (also used by References). Unchanged behaviour.
function showModal(src) {
  $("imgModalImg").src = src;
  $("imgModal").hidden = false;
}
$("imgModal").onclick = () => { $("imgModal").hidden = true; };

/* ---- carousel slide lightbox ---- */
const lb = { slides: [], i: 0 };
function openLightbox(urls, start) {
  lb.slides = (urls || []).slice();
  if (!lb.slides.length) return;
  lb.i = Math.max(0, Math.min(start || 0, lb.slides.length - 1));
  renderLightbox();
  $("lightbox").hidden = false;
}
function renderLightbox() {
  if (!lb.slides.length) { closeLightbox(); return; }
  lb.i = ((lb.i % lb.slides.length) + lb.slides.length) % lb.slides.length;
  $("lbImg").src = lb.slides[lb.i];
  $("lbCounter").textContent = `${lb.i + 1} / ${lb.slides.length}`;
  const multi = lb.slides.length > 1;
  $("lbPrev").hidden = !multi;
  $("lbNext").hidden = !multi;
  $("lbCounter").hidden = !multi;
}
function lbStep(d) { lb.i += d; renderLightbox(); }
function closeLightbox() { $("lightbox").hidden = true; lb.slides = []; }
$("lbPrev").onclick = (e) => { e.stopPropagation(); lbStep(-1); };
$("lbNext").onclick = (e) => { e.stopPropagation(); lbStep(1); };
$("lbClose").onclick = closeLightbox;
$("lbImg").onclick = (e) => e.stopPropagation();
$("lightbox").onclick = closeLightbox;   // click the backdrop to close
$("lbImg").onerror = () => {             // skip a 404 slide inside the gallery
  if ($("lightbox").hidden || !lb.slides.length) return;
  lb.slides.splice(lb.i, 1);
  if (!lb.slides.length) { closeLightbox(); toast("No slide images available", true); return; }
  if (lb.i >= lb.slides.length) lb.i = 0;
  renderLightbox();
};
document.addEventListener("keydown", (e) => {
  if ($("lightbox").hidden) return;
  if (e.key === "Escape") closeLightbox();
  else if (e.key === "ArrowLeft") lbStep(-1);
  else if (e.key === "ArrowRight") lbStep(1);
});

/* ---------------- settings ---------------- */

// Layout for the restyled Settings tab. Each dual range maps its two handles to
// a _min/_max config key; singles map to one key; toggles to a bool key. The
// same key names feed the unchanged save_config call.
const SETTINGS_LAYOUT = {
  main: [
    { title: "Timing", icon: "clock", desc: "How long the finder dwells on each video and how it swipes.",
      rows: [
        { type: "range", label: "Watch time per video", unit: "s", min: 0, max: 60, step: 0.1, keys: ["dwell_min", "dwell_max"] },
        { type: "range", label: "Swipe duration", unit: "s", min: 0.1, max: 3, step: 0.05, keys: ["swipe_duration_min", "swipe_duration_max"] },
        { type: "range", label: "Pause after swipe", unit: "s", min: 0, max: 15, step: 0.1, keys: ["post_swipe_delay_min", "post_swipe_delay_max"] },
      ] },
    { title: "Long pause", icon: "pause", desc: "Occasional longer breaks so activity looks human.",
      rows: [
        { type: "range", label: "Take a long pause every", unit: " videos", min: 1, max: 200, step: 1, keys: ["long_pause_every_min", "long_pause_every_max"] },
        { type: "range", label: "Long pause length", unit: "s", min: 0, max: 600, step: 1, keys: ["long_pause_min", "long_pause_max"] },
        { type: "single", label: "Max session length", unit: " min", min: 1, max: 1440, step: 1, key: "session_max_minutes" },
      ] },
    { title: "Carousel", icon: "images", desc: "Capturing multi-slide carousel ads.",
      rows: [
        { type: "toggle", label: "Capture carousel slides", key: "carousel_capture" },
        { type: "single", label: "Max slides to scan", unit: "", min: 1, max: 20, step: 1, key: "carousel_max_slides" },
        { type: "range", label: "Delay between slides", unit: "s", min: 0, max: 10, step: 0.1, keys: ["carousel_delay_min", "carousel_delay_max"] },
      ] },
    { title: "Liking", icon: "heart",
      desc: "Likes ads the AI is confident are in-niche, to steer the For-You feed toward more of them. For throwaway accounts only — automated liking can flag a real account.",
      rows: [
        { type: "toggle", label: "Like confident niche ads", key: "like_niche_ads" },
        { type: "single", key: "like_chance", min: 0, max: 1, step: 0.05, pct: true, unit: "%", tpl: "Like ~{v} of them", gatedBy: "like_niche_ads" },
        { type: "single", label: "Max likes per window", unit: "", min: 1, max: 200, step: 1, key: "like_max_per_window", gatedBy: "like_niche_ads" },
        { type: "single", label: "Window length", unit: " min", min: 15, max: 360, step: 1, key: "like_window_minutes", gatedBy: "like_niche_ads" },
      ] },
    { title: "Capture & Behaviour", icon: "capture",
      desc: "How the finder samples each video and how human-like it behaves while scrolling.",
      rows: [
        { type: "single", label: "Frames sampled per video", unit: "", min: 1, max: 5, step: 1, key: "capture_frames",
          hint: "More frames = more likely to catch the caption (TikTok captions render a moment after the video starts). 1 = single screenshot." },
        { type: "toggle", label: "Occasionally browse organic slideshows", key: "browse_slideshows" },
        { type: "single", key: "browse_slideshow_chance", min: 0, max: 1, step: 0.05, pct: true, unit: "%", tpl: "~{v} of organic carousels", gatedBy: "browse_slideshows" },
        { type: "single", key: "browse_slideshow_max", min: 1, max: 6, step: 1, tpl: "Up to {v} slides", gatedBy: "browse_slideshows" },
      ] },
  ],
  side: [
    { title: "Movement", rows: [
        { type: "single", label: "Swipe randomness", unit: "px", min: 0, max: 200, step: 1, key: "swipe_jitter_px" } ] },
    { title: "Advanced", rows: [
        { type: "single", label: "Re-read config every", unit: " videos", min: 1, max: 100, step: 1, key: "config_poll_videos" } ] },
  ],
};

const CARD_ICONS = {
  clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
  pause: '<svg viewBox="0 0 24 24"><rect x="7" y="5" width="3.5" height="14" rx="1"/><rect x="13.5" y="5" width="3.5" height="14" rx="1"/></svg>',
  images: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="14" height="14" rx="2"/><path d="M7 20h12a2 2 0 0 0 2-2V8"/></svg>',
  heart: '<svg viewBox="0 0 24 24"><path d="M12 20s-7-4.6-9.3-9C1.3 8 2.7 5 5.6 5 7.5 5 9 6.2 12 9c3-2.8 4.5-4 6.4-4 2.9 0 4.3 3 2.9 6-2.3 4.4-9.3 9-9.3 9z"/></svg>',
  capture: '<svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="14" rx="2"/><circle cx="12" cy="13" r="3.4"/><path d="M8.5 6l1.2-2h4.6l1.2 2"/></svg>',
};

function sliderPct(v, min, max) { return ((v - min) / (max - min)) * 100; }
function sliderFmt(n, step) {
  const d = step < 0.1 ? 2 : (step < 1 ? 1 : 0);
  return (+n).toFixed(d);
}

// Reusable dual-handle range slider (two overlapping range inputs, highlighted
// track between the handles). Handles can't cross. Used across the Settings cards.
function makeRange(spec, values, onChange) {
  const [loKey, hiKey] = spec.keys;
  const lo0 = values[loKey] ?? spec.min, hi0 = values[hiKey] ?? spec.max;
  const wrap = document.createElement("div");
  wrap.className = "slider-block";
  wrap.innerHTML =
    `<div class="slider-label"><span class="slk">${esc(spec.label)}:</span> <span class="slv"></span></div>
     <div class="rng"><div class="rng-track"></div><div class="rng-fill"></div>
       <input type="range" data-key="${loKey}" min="${spec.min}" max="${spec.max}" step="${spec.step}" value="${lo0}">
       <input type="range" data-key="${hiKey}" min="${spec.min}" max="${spec.max}" step="${spec.step}" value="${hi0}">
     </div>`;
  const [loI, hiI] = wrap.querySelectorAll("input");
  const fill = wrap.querySelector(".rng-fill");
  const slv = wrap.querySelector(".slv");
  function render() {
    const a = Math.min(+loI.value, +hiI.value), b = Math.max(+loI.value, +hiI.value);
    fill.style.left = sliderPct(a, spec.min, spec.max) + "%";
    fill.style.width = (sliderPct(b, spec.min, spec.max) - sliderPct(a, spec.min, spec.max)) + "%";
    slv.textContent = `${sliderFmt(a, spec.step)} – ${sliderFmt(b, spec.step)}${spec.unit || ""}`;
  }
  loI.addEventListener("input", () => { if (+loI.value > +hiI.value) loI.value = hiI.value; render(); onChange && onChange(); });
  hiI.addEventListener("input", () => { if (+hiI.value < +loI.value) hiI.value = loI.value; render(); onChange && onChange(); });
  render();
  return wrap;
}

// spec.pct → show value as a percentage (slider still stores the 0–1 raw value).
// spec.tpl → a label template with "{v}" where the value goes (e.g. "Up to {v} slides").
// spec.hint → a muted line under the slider.
function singleValueText(spec, v) {
  return (spec.pct ? Math.round(v * 100) : sliderFmt(v, spec.step)) + (spec.unit || "");
}
function singleLabelHtml(spec, v) {
  const val = `<span class="slv">${singleValueText(spec, v)}</span>`;
  return spec.tpl ? spec.tpl.replace("{v}", val)
                  : `<span class="slk">${esc(spec.label)}:</span> ${val}`;
}
function makeSingle(spec, values, onChange) {
  const v0 = values[spec.key] ?? spec.min;
  const wrap = document.createElement("div");
  wrap.className = "slider-block";
  wrap.innerHTML =
    `<div class="slider-label">${singleLabelHtml(spec, v0)}</div>
     <div class="rng single"><div class="rng-track"></div><div class="rng-fill"></div>
       <input type="range" data-key="${spec.key}" min="${spec.min}" max="${spec.max}" step="${spec.step}" value="${v0}">
     </div>` + (spec.hint ? `<p class="hint slider-hint">${esc(spec.hint)}</p>` : "");
  const inp = wrap.querySelector("input");
  const fill = wrap.querySelector(".rng-fill");
  const label = wrap.querySelector(".slider-label");
  function render() {
    fill.style.left = "0%";
    fill.style.width = sliderPct(+inp.value, spec.min, spec.max) + "%";
    label.innerHTML = singleLabelHtml(spec, +inp.value);
  }
  inp.addEventListener("input", () => { render(); onChange && onChange(); });
  render();
  return wrap;
}

function makeToggle(spec, values) {
  const wrap = document.createElement("div");
  wrap.className = "slider-block toggle-block";
  wrap.innerHTML =
    `<label class="toggle"><input type="checkbox" data-key="${spec.key}" ${values[spec.key] ? "checked" : ""}><span></span>${esc(spec.label)}</label>`;
  return wrap;
}

function settingsCard(card, values, mini) {
  const el = document.createElement("div");
  el.className = "panel settings-card" + (mini ? " mini" : "");
  const head = mini
    ? `<span class="mini-lbl">${esc(card.title)}</span>`
    : `${CARD_ICONS[card.icon] || ""}${esc(card.title)}`;
  el.innerHTML = `<h2>${head}</h2>` + (card.desc ? `<p class="hint">${esc(card.desc)}</p>` : "") + `<div class="card-rows"></div>`;
  const rows = el.querySelector(".card-rows");
  for (const r of card.rows) {
    let node;
    if (r.type === "range") node = makeRange(r, values, validatePairs);
    else if (r.type === "single") node = makeSingle(r, values, validatePairs);
    else node = makeToggle(r, values);
    if (r.gatedBy) node.dataset.gatedBy = r.gatedBy;   // dims when its toggle is off
    rows.appendChild(node);
  }
  // Rows gated by a toggle dim (but still save) while that toggle is off.
  const gateKeys = [...new Set(card.rows.filter(r => r.gatedBy).map(r => r.gatedBy))];
  for (const key of gateKeys) {
    const toggle = el.querySelector(`input[type=checkbox][data-key="${key}"]`);
    if (!toggle) continue;
    const apply = () => el.querySelectorAll(`[data-gated-by="${key}"]`)
      .forEach(n => n.classList.toggle("dimmed", !toggle.checked));
    toggle.addEventListener("change", apply);
    apply();
  }
  return el;
}

function buildSliders(values) {
  const main = $("configMain"), side = $("configSide");
  main.innerHTML = ""; side.innerHTML = "";
  for (const card of SETTINGS_LAYOUT.main) main.appendChild(settingsCard(card, values, false));
  for (const card of SETTINGS_LAYOUT.side) side.appendChild(settingsCard(card, values, true));
  validatePairs();
}

function sliderValues() {
  const out = {};
  document.querySelectorAll("#view-settings [data-key]").forEach(inp => {
    out[inp.dataset.key] = inp.type === "checkbox" ? inp.checked : +inp.value;
  });
  return out;
}

// Dual sliders clamp so min ≤ max always holds; nothing to block on save.
function validatePairs() {
  $("btnConfigSave").disabled = false;
  return true;
}

async function loadSettings() {
  if (!state.boot.env_ok) { buildSliders({}); return; }
  const res = await api().get_config();
  if (!res.ok) { toast(res.error, true); buildSliders({}); return; }
  // Defaults first; a present-and-non-null config value overrides (null keeps the default).
  const merged = Object.assign({}, res.defaults);
  const cfg = res.config || {};
  for (const k in cfg) if (cfg[k] !== null && cfg[k] !== undefined) merged[k] = cfg[k];
  buildSliders(merged);
}

$("btnConfigSave").onclick = async () => {
  if (!validatePairs()) return;
  const r = await api().save_config(sliderValues());
  $("configMsg").textContent = r.ok ? "Saved ✓" : r.error;
  $("configMsg").className = "savemsg" + (r.ok ? "" : " err");
  setTimeout(() => { $("configMsg").textContent = ""; }, 3000);
};

$("btnConfigReset").onclick = async () => {
  const r = await api().reset_config();
  if (r.ok) { buildSliders(r.config); toast("Defaults restored and saved"); }
  else toast(r.error, true);
};

$("btnAppSave").onclick = async () => {
  const b = await api().save_app_settings({
    spark_dir: $("setSparkDir").value,
    wda_dir: $("setWdaDir").value,
    udid: $("setUdid").value,
  });
  state.boot = b;
  $("grabberMissing").hidden = b.code_grabber_exists;
  $("appMsg").textContent = "Saved ✓";
  setTimeout(() => { $("appMsg").textContent = ""; }, 3000);
  if (!b.env_ok) toast("Note: spark.env not found in that folder", true);
};

/* ---------------- references ---------------- */

// Both sets live in one view now. Each is a Supabase bucket; a thumbnail's chip
// shows its set and flips it (move between buckets). Uploads go to the set picked
// in the uploader's "Add to" toggle (default: Ideal ads).
const REF_ORDER = ["spark-reference-ads", "spark-reference-noncompliant"];
const REF_LABELS = { "spark-reference-ads": "Ideal ads", "spark-reference-noncompliant": "Non-compliant" };
const REF_CAPTIONS = { "spark-reference-ads": "what to catch", "spark-reference-noncompliant": "what to flag" };
const REF_CHIP = { "spark-reference-ads": "Ideal", "spark-reference-noncompliant": "Flag" };
const REF_OTHER = { "spark-reference-ads": "spark-reference-noncompliant", "spark-reference-noncompliant": "spark-reference-ads" };
const REF_MAX = 5;

async function loadReferences() {
  if (!state.boot.env_ok) return;
  state.refFiles = {};
  for (const b of REF_ORDER) {
    const res = await api().list_references(b);
    state.refFiles[b] = (res && res.ok) ? res.files : [];
    if (res && !res.ok) toast(b + ": " + res.error, true);
  }
  renderReferences();
}

function refClass(b) { return b === "spark-reference-ads" ? "ideal" : "flag"; }

function renderReferences() {
  const total = REF_ORDER.reduce((n, b) => n + (state.refFiles[b] || []).length, 0);
  $("refGroups").innerHTML = REF_ORDER.map(b => {
    const files = state.refFiles[b] || [];
    const other = REF_LABELS[REF_OTHER[b]];
    const thumbs = files.map(f => `
      <div class="refitem ${refClass(b)}" data-bucket="${esc(b)}" data-name="${esc(f.name)}">
        ${f.url ? `<img src="${esc(f.url)}" loading="lazy">` : `<div class="empty">no preview</div>`}
        <button class="ref-chip ${refClass(b)}" title="Move to ${esc(other)}">${esc(REF_CHIP[b])}</button>
        <button class="del" title="Delete">✕</button>
      </div>`).join("");
    return `
      <div class="ref-group">
        <div class="ref-group-head">
          <span class="ref-group-title">${esc(REF_LABELS[b])} <span class="hint">— ${esc(REF_CAPTIONS[b])}</span></span>
          <span class="ref-group-slots ${files.length >= REF_MAX ? "full" : ""}">${files.length} of ${REF_MAX}</span>
        </div>
        <div class="thumbgrid">${thumbs || `<div class="hint ref-group-empty">none yet</div>`}</div>
      </div>`;
  }).join("");
  $("refEmpty").hidden = total > 0;
  syncRefUploader();
}

// Uploader reflects the selected target set: its slot count + greying when full.
function syncRefUploader() {
  const b = state.refTarget;
  const used = (state.refFiles[b] || []).length;
  const full = used >= REF_MAX;
  $("refSlots").textContent = full
    ? `${REF_LABELS[b]} is full (${REF_MAX} of ${REF_MAX}) — delete one to add more`
    : `${used} of ${REF_MAX} used in ${REF_LABELS[b]}`;
  $("refUploader").classList.toggle("full", full);
  document.querySelectorAll("#refTargetSeg .seg-btn").forEach(x =>
    x.classList.toggle("active", x.dataset.bucket === b));
}

$("refTargetSeg").addEventListener("click", (e) => {
  const btn = e.target.closest(".seg-btn");
  if (!btn) return;
  state.refTarget = btn.dataset.bucket;
  syncRefUploader();
});

function refUpload(fileList) {
  if ($("refUploader").classList.contains("full")) {
    toast(`${REF_LABELS[state.refTarget]} is full (${REF_MAX}). Delete one first.`, true);
    return;
  }
  for (const file of fileList) {
    if (!file.type.startsWith("image/")) continue;
    const reader = new FileReader();
    reader.onload = async () => {
      const r = await api().upload_reference(state.refTarget, file.name, reader.result);
      toast(r.ok ? `Added to ${REF_LABELS[state.refTarget]}` : r.error, !r.ok);
      if (r.ok) loadReferences();
    };
    reader.readAsDataURL(file);
  }
}

$("refUploader").onclick = (e) => {
  if (e.target.closest("#refTargetSeg")) return;   // the toggle handles its own clicks
  if (!$("refUploader").classList.contains("full")) $("refFileInput").click();
};
$("refBrowse").onclick = (e) => { e.stopPropagation(); $("refFileInput").click(); };
$("refFileInput").onchange = () => { refUpload($("refFileInput").files); $("refFileInput").value = ""; };
["dragenter", "dragover"].forEach(ev => $("refUploader").addEventListener(ev, (e) => {
  e.preventDefault();
  if (!$("refUploader").classList.contains("full")) $("refUploader").classList.add("dragover");
}));
["dragleave", "drop"].forEach(ev => $("refUploader").addEventListener(ev, (e) => {
  e.preventDefault();
  $("refUploader").classList.remove("dragover");
}));
$("refUploader").addEventListener("drop", (e) => { e.preventDefault(); refUpload(e.dataTransfer.files); });

$("refGroups").addEventListener("click", async (e) => {
  const item = e.target.closest(".refitem");
  if (!item) return;
  const bucket = item.dataset.bucket, name = item.dataset.name;
  if (e.target.closest(".ref-chip")) {
    const dest = REF_OTHER[bucket];
    const r = await api().move_reference(bucket, dest, name);
    toast(r.ok ? `Moved to ${REF_LABELS[dest]}` : r.error, !r.ok);
    if (r.ok) loadReferences();
  } else if (e.target.closest(".del")) {
    const r = await api().delete_reference(bucket, name);
    toast(r.ok ? "Deleted" : r.error, !r.ok);
    if (r.ok) loadReferences();
  } else if (e.target.tagName === "IMG") {
    showModal(e.target.src);
  }
});

/* ---------------- flows ---------------- */
// One view of the pipeline. Stage cards read counts/last-run from get_flows; the
// run buttons REUSE the existing tabs' start logic (no reimplementation) and
// inherit their phone-readiness gating. Format Finder is stubbed (not installed),
// and the shoot-brief queue is deliberately not faked.

const FLOW_STAGES = [
  { key: "ad_finder",     name: "Ad Finder",      kind: "tool" },
  { key: "format_finder", name: "Format Finder",  kind: "tool" },
  { key: "produce",       name: "Produce images", kind: "manual" },
  { key: "poster",        name: "Poster",         kind: "tool" },
  { key: "spark_code",    name: "Spark code",     kind: "tool" },
];
// running tool name -> the stage card it belongs to
const FLOW_TOOL_STAGE = { ad_finder: "ad_finder", code_grabber: "spark_code",
                         debug_screen: "spark_code", poster: "poster" };

function fmtAgo(ts) {
  if (!ts) return "never run";
  const t = typeof ts === "number" ? ts * 1000 : Date.parse(ts);
  if (!t) return "—";
  const s = Math.floor((Date.now() - t) / 1000);
  if (s < 60) return "just now";
  if (s < 3600) return `ran ${Math.floor(s / 60)}m ago`;
  if (s < 86400) return `ran ${Math.floor(s / 3600)}h ago`;
  return `ran ${Math.floor(s / 86400)}d ago`;
}
const flowNum = n => (n === null || n === undefined) ? "—" : n;

async function loadFlows() {
  updateFlows(state.status);
  let res;
  try { res = await api().get_flows(); } catch { return; }
  if (res && res.ok) { state.flows = res.stages; renderFlows(); }
}

function flowCount(key, s) {
  const installed = state.flows && state.flows.format_finder && state.flows.format_finder.installed;
  switch (key) {
    case "ad_finder":     return `<span class="num mono">${flowNum(s.count)}</span><span class="flow-lbl">ads found</span>`;
    case "format_finder": return s.installed
      ? `<span class="num mono">${flowNum(s.count)}</span><span class="flow-lbl">formats</span>`
      : `<span class="flow-stub">not installed</span>`;
    case "produce":       return installed
      ? `<span class="num mono">—</span><span class="flow-lbl">briefs waiting</span>`
      : `<span class="flow-stub">waiting on Format Finder</span>`;
    case "poster":        return `<span class="num mono">${flowNum(s.queued)}</span><span class="flow-lbl">queued</span>`
      + `<span class="flow-sub mono">${flowNum(s.published)} posted</span>`;
    case "spark_code":    return `<span class="num mono">${flowNum(s.count)}</span><span class="flow-lbl">codes captured</span>`;
  }
  return "";
}

function flowAction(key) {
  switch (key) {
    case "ad_finder":     return `<button class="btn small accent flow-run" data-run="ad_finder">&#9654; Run</button>`;
    case "format_finder": return `<button class="btn small flow-run" data-run="format_finder" disabled>Install to enable</button>`;
    case "produce":       return `<span class="flow-manual">your step — briefs below</span>`;
    case "poster":        return `<button class="btn small flow-run" data-run="poster">Open Poster &rarr;</button>`;
    case "spark_code":    return `<button class="btn small accent flow-run" data-run="spark_code">&#9654; Grab</button>`;
  }
  return "";
}

function renderFlows() {
  if (!state.flows) return;
  $("flowRow").innerHTML = FLOW_STAGES.map((stg, i) => {
    const s = state.flows[stg.key] || {};
    const showLast = stg.kind === "tool" && stg.key !== "format_finder";
    const last = showLast ? `<div class="flow-last mono">${fmtAgo(s.last_run)}</div>` : `<div class="flow-last"></div>`;
    const arrow = i < FLOW_STAGES.length - 1 ? `<div class="flow-arrow">&rarr;</div>` : "";
    return `
      <div class="flow-stage ${stg.kind}" data-stage="${stg.key}">
        <div class="flow-stage-head"><span class="flow-stage-name">${esc(stg.name)}</span>
          <span class="flow-kind">${stg.kind === "manual" ? "you" : "tool"}</span></div>
        <div class="flow-count">${flowCount(stg.key, s)}</div>
        ${last}
        <div class="flow-action">${flowAction(stg.key)}</div>
      </div>${arrow}`;
  }).join("");

  const installed = state.flows.format_finder && state.flows.format_finder.installed;
  $("flowBriefs").innerHTML = installed
    ? `<div class="empty">No shoot briefs waiting.</div>`
    : `<div class="flow-stub-panel"><b>Format Finder isn't installed yet.</b> Once
        <code class="mono">spark_format_finder.py</code> is in your spark-phone folder and writing shoot briefs to
        <code class="mono">spark_formats</code>, each waiting brief will appear here — its format name, the
        slide-by-slide brief, a <b>Copy brief</b> button, and a control to mark it done once its images are in the
        poster folder.</div>`;
  $("flowBriefsMeta").textContent = installed ? "" : "stubbed — awaiting Format Finder";
  updateFlows(state.status);
}

function updateFlows(st) {
  const running = !!(st && st.tool && st.tool.running);
  const runStage = running ? FLOW_TOOL_STAGE[st.tool.name] : null;

  const banner = $("flowNotReady");
  if (st && !st.phone.ok) {
    banner.hidden = false; $("flowNotReadyText").textContent = "iPhone not connected — plug it in, unlock it, and tap Trust."; $("flowStartEverything").hidden = true;
  } else if (st && !st.wda.ok) {
    banner.hidden = false; $("flowNotReadyText").textContent = "WebDriverAgent isn't running yet."; $("flowStartEverything").hidden = false;
  } else { banner.hidden = true; }

  const ready = !!(st && st.phone.ok && st.wda.ok);
  document.querySelectorAll("#flowRow .flow-run").forEach(btn => {
    const stage = btn.dataset.run;
    if (stage === "format_finder" || stage === "poster") return;   // stub / navigation
    btn.disabled = !ready || (running && runStage !== stage);
  });
  document.querySelectorAll("#flowRow .flow-stage").forEach(card => {
    const isRun = card.dataset.stage === runStage;
    card.classList.toggle("running", isRun);
    const act = card.querySelector(".flow-action");
    if (isRun && act && !act.querySelector(".flow-stop"))
      act.innerHTML = `<button class="btn small danger flow-stop">&#9632; Stop</button>`;
  });
  if (state.flowWasRunning && !running) loadFlows();   // refresh counts when a stage finishes
  state.flowWasRunning = running;

  const show = running || $("flowOutput").childElementCount > 0;
  $("flowOutput").hidden = !show; $("flowEmpty").hidden = show;
}

$("flowRow").addEventListener("click", (e) => {
  if (e.target.closest(".flow-stop")) { api().stop_tool().then(pollStatus); return; }
  const btn = e.target.closest(".flow-run"); if (!btn || btn.disabled) return;
  const stage = btn.dataset.run;
  $("flowOutput").innerHTML = "";
  if (stage === "ad_finder") $("btnAfStart").click();          // reuse existing starts
  else if (stage === "spark_code") $("btnCgStart").click();
  else if (stage === "poster") showView("poster");            // deliberate + gated on its tab
});
$("flowStartEverything").onclick = () => $("btnEverything").click();

/* ---------------- setup wizard ---------------- */

const SETUP_ICON = { ok: "✓", warn: "!", fail: "✕" };
let setupLast = null;   // most recent get_setup_status result

async function refreshSetup() {
  const res = await api().get_setup_status();
  setupLast = res;
  renderSetup(res);
  return res;
}
function loadSetup() { refreshSetup(); }

// Device-first layout: the Device card handles iphone/WDA/iproxy; the remaining
// requirement checks are split into "needs attention" (failing, expanded) and a
// collapsed "System requirements" card (passing). ad_settings is its own optional card.
const SETUP_REQ_GROUPS = [
  { title: "System", ids: ["xcode", "homebrew", "libimobiledevice", "tesseract", "venv"] },
  { title: "Spark", ids: ["scripts", "spark_env", "webdriveragent"] },
];
const SETUP_OPTIONAL_IDS = ["mjpeg_stream", "importer", "ad_settings"];

function renderSetup(res) {
  const body = $("setupBody");
  if (!res || !res.checks) {
    body.innerHTML = '<div class="empty">Couldn\'t run setup checks.</div>';
    return;
  }
  const byId = {};
  res.checks.forEach(c => { byId[c.id] = c; });
  body.innerHTML = "";

  body.appendChild(setupSummary(res));
  body.appendChild(setupDeviceCard(byId));

  const reqIds = SETUP_REQ_GROUPS.flatMap(g => g.ids);
  const attention = reqIds.map(id => byId[id]).filter(c => c && c.status !== "ok");
  if (attention.length) body.appendChild(setupAttention(attention));

  body.appendChild(setupRequirementsCard(byId, reqIds));

  const opt = SETUP_OPTIONAL_IDS.map(id => byId[id]).filter(Boolean);
  if (opt.length) body.appendChild(setupOptionalCard(opt));
}

function setupSummary(res) {
  const total = res.checks.length;
  const passing = res.checks.filter(c => c.status === "ok").length;
  const needs = res.checks.filter(c => !c.optional && c.status !== "ok").length;
  const el = document.createElement("div");
  el.className = "setup-summary";
  el.innerHTML = `Checks: <b>${total}</b> &nbsp;·&nbsp; Passing: <b>${passing}</b> &nbsp;·&nbsp; `
    + `<span class="sum-needs ${needs ? "needs" : "clear"}">Needs attention: <b>${needs}</b></span>`;
  return el;
}

function setupDeviceCard(byId) {
  const ip = byId.iphone || {}, meta = ip.meta || {};
  const wdaCheck = byId.wda_responding || {};
  const connected = ip.status === "ok";
  const st = state.status || {};
  const wdaOk = st.wda ? st.wda.ok : (wdaCheck.status === "ok");
  const iproxyOk = st.iproxy ? st.iproxy.ok : null;

  const card = document.createElement("div");
  card.className = "panel device-card";
  const pill = connected ? '<span class="status-pill ok">Connected</span>'
    : `<span class="status-pill ${ip.status === "warn" ? "warn" : "fail"}">${ip.status === "warn" ? "Wrong device" : "Not connected"}</span>`;
  const udid = meta.configured || "";
  const idline = connected
    ? `${meta.ios_version ? "iOS " + esc(meta.ios_version) + "  ·  " : ""}${udid ? esc(udid.slice(0, 8) + "…" + udid.slice(-4)) : ""}`
    : esc(ip.detail || "not connected");

  const svc = (label, ok) => {
    const cls = ok === true ? "ok" : ok === false ? "bad" : "";
    const txt = ok === true ? "responding" : ok === false ? "down" : "—";
    return `<li><span class="dot ${cls}"></span><b>${label}</b><span class="detail">${txt}</span></li>`;
  };

  card.innerHTML = `
    <div class="panel-head">
      <h2><svg viewBox="0 0 24 24"><rect x="6" y="2.5" width="12" height="19" rx="2.5"/><line x1="10" y1="18.5" x2="14" y2="18.5"/></svg>Device</h2>
      ${pill}
    </div>
    <div class="device-main">
      <span class="dev-dot dot ${connected ? "ok" : "bad"}"></span>
      <div class="dev-id">
        <div class="dev-name">${esc(meta.name || (connected ? "iPhone" : "No device"))}</div>
        <div class="dev-meta mono">${idline}</div>
      </div>
      <ul class="device-svcs statuslist">
        ${svc("WebDriverAgent", wdaOk)}
        ${svc("iproxy 8100", iproxyOk)}
      </ul>
    </div>`;

  const foot = document.createElement("div");
  if (connected && (!wdaOk || iproxyOk === false)) {
    foot.className = "btnrow";
    foot.innerHTML = '<span class="hint">WebDriverAgent / iproxy aren\'t up.</span>';
    const btn = mkBtn("Start everything", "accent", () => { showView("dashboard"); $("btnEverything").click(); });
    foot.insertBefore(btn, foot.firstChild);
    card.appendChild(foot);
  } else if (!connected) {
    foot.className = "setup-instructions";
    foot.textContent = ip.instructions || "Plug the iPhone in, unlock it, and tap Trust.";
    card.appendChild(foot);
    if (meta.offer_save && meta.suggest_udid) {
      const box = document.createElement("div");
      box.className = "udid-suggest";
      box.innerHTML = `<span>Detected <span class="mono">${esc(meta.suggest_udid)}</span></span>`;
      box.appendChild(mkBtn("Save to Settings", "", async () => {
        const r = await api().save_found_udid(meta.suggest_udid);
        toast(r.ok ? "UDID saved to Settings" : r.error, !r.ok);
        if (r.ok) refreshSetup();
      }));
      card.appendChild(box);
    }
  }
  return card;
}

function setupAttention(rows) {
  const wrap = document.createElement("div");
  wrap.className = "setup-section";
  wrap.innerHTML = '<div class="setup-section-label attn">Needs attention</div>';
  const list = document.createElement("div");
  list.className = "setup-list";
  rows.forEach(c => list.appendChild(setupRow(c)));
  wrap.appendChild(list);
  return wrap;
}

function collapseCard(title, pillCls, pillText) {
  const card = document.createElement("div");
  card.className = "panel collapse-card";
  card.innerHTML = `
    <button class="collapse-head" aria-expanded="false">
      <span class="chev">&#9656;</span><span class="ch-title">${esc(title)}</span>
      <span class="status-pill ${pillCls}">${esc(pillText)}</span>
    </button>
    <div class="collapse-body" hidden></div>`;
  return card;
}

function setupRequirementsCard(byId, reqIds) {
  const reqs = reqIds.map(id => byId[id]).filter(Boolean);
  const passing = reqs.filter(c => c.status === "ok");
  const total = reqs.length;
  const allPass = passing.length === total;
  const card = collapseCard("System requirements",
    allPass ? "ok" : "warn",
    allPass ? `all ${total} passing ✓` : `${passing.length} of ${total} passing`);
  const list = document.createElement("div");
  list.className = "setup-list";
  for (const g of SETUP_REQ_GROUPS) {
    const groupRows = g.ids.map(id => byId[id]).filter(c => c && c.status === "ok");
    if (!groupRows.length) continue;
    const gh = document.createElement("div");
    gh.className = "setup-group";
    gh.textContent = g.title;
    list.appendChild(gh);
    groupRows.forEach(c => list.appendChild(setupRow(c)));
  }
  card.querySelector(".collapse-body").appendChild(list);
  return card;
}

function setupOptionalCard(rows) {
  const c = rows[0];
  const done = c.status === "ok";
  const card = collapseCard("Optional", done ? "ok" : "warn", done ? "done" : "action needed");
  const list = document.createElement("div");
  list.className = "setup-list";
  rows.forEach(r => list.appendChild(setupRow(r)));
  card.querySelector(".collapse-body").appendChild(list);
  return card;
}

// Generic collapse toggle for any .collapse-head (setup cards + signing trust panel).
document.addEventListener("click", (e) => {
  const head = e.target.closest(".collapse-head");
  if (!head) return;
  const open = head.getAttribute("aria-expanded") === "true";
  head.setAttribute("aria-expanded", String(!open));
  const body = head.nextElementSibling;
  if (body && body.classList.contains("collapse-body")) body.hidden = open;
});

function mkBtn(label, kind, onClick) {
  const b = document.createElement("button");
  b.className = "btn small" + (kind ? " " + kind : "");
  b.textContent = label;
  b.onclick = onClick;
  return b;
}

function setupRow(c) {
  const item = document.createElement("div");
  item.className = "setup-item status-" + c.status;
  item.dataset.id = c.id;
  const optTag = c.optional ? '<span class="setup-opt">optional</span>' : "";
  const row = document.createElement("div");
  row.className = "setup-row";
  row.innerHTML = `
    <span class="setup-ic">${SETUP_ICON[c.status] || "?"}</span>
    <span class="setup-name">${esc(c.name)}${optTag}</span>
    <span class="setup-detail" title="${esc(c.detail || "")}">${esc(c.detail || "")}</span>
    <span class="setup-actions"></span>`;
  setupActions(c, row.querySelector(".setup-actions"));
  item.appendChild(row);
  const extra = setupExtra(c);          // instructions / env form / udid save, below the row
  if (extra) item.appendChild(extra);
  return item;
}

function setupInstructionsHtml(c) {
  let html = esc(c.instructions || "");
  if (c.meta && c.meta.appstore) {
    html += `\n<a href="#" class="err-link" data-open="${esc(c.meta.appstore)}">Open App Store →</a>`;
  }
  return html;
}

// Right-aligned action on the row: mark-done toggle, Start everything, Fix, or spinner.
function setupActions(c, actions) {
  if (c.fix_kind === "mark_done") {
    const lbl = document.createElement("label");
    lbl.className = "toggle small";
    lbl.innerHTML = `<input type="checkbox" ${c.status === "ok" ? "checked" : ""}><span></span>Mark as done`;
    lbl.querySelector("input").onchange = async (e) => {
      const r = await api().mark_ad_settings(e.target.checked);
      if (!r.ok) { toast(r.error, true); e.target.checked = !e.target.checked; return; }
      if (state.boot) state.boot.ad_settings_done = e.target.checked;  // Spark Codes note reflects it
      refreshSetup();
    };
    actions.appendChild(lbl);
    return;
  }
  if (c.status === "ok") return;
  if (c.fix_kind === "start_everything") {
    actions.appendChild(mkBtn("Start everything", "accent", () => {
      showView("dashboard");
      $("btnEverything").click();
    }));
    return;
  }
  if (c.fixable && !c.fix_kind) {
    if (state.setupFixRunning === c.id) {
      actions.innerHTML = '<span class="setup-spin"><span class="spinner"></span>Working…</span>';
    } else {
      actions.appendChild(mkBtn(c.fix_label || "Fix", "accent", (e) => runSetupFix(c.id, e.target)));
    }
  }
}

// Sub-block shown below the row only when needed: instructions, the env form, or
// the "save detected UDID" offer. Returns null when there's nothing to show.
function setupExtra(c) {
  const parts = [];
  if (c.status !== "ok" && c.instructions && c.fix_kind !== "env_form") {
    const ins = document.createElement("div");
    ins.className = "setup-instructions";
    ins.innerHTML = setupInstructionsHtml(c);
    parts.push(ins);
  }
  if (c.fix_kind === "env_form" && c.status !== "ok") parts.push(envForm(c));
  if (c.id === "iphone" && c.meta && c.meta.offer_save && c.meta.suggest_udid) {
    const box = document.createElement("div");
    box.className = "udid-suggest";
    box.innerHTML = `<span>Detected <span class="mono">${esc(c.meta.suggest_udid)}</span></span>`;
    box.appendChild(mkBtn("Save to Settings", "", async () => {
      const r = await api().save_found_udid(c.meta.suggest_udid);
      toast(r.ok ? "UDID saved to Settings" : r.error, !r.ok);
      if (r.ok) refreshSetup();
    }));
    parts.push(box);
  }
  if (!parts.length) return null;
  const wrap = document.createElement("div");
  wrap.className = "setup-extra";
  parts.forEach(p => wrap.appendChild(p));
  return wrap;
}

function envForm(c) {
  const form = document.createElement("div");
  form.className = "env-form";
  const keys = (c.meta && c.meta.keys) || [];
  const masked = (c.meta && c.meta.masked) || {};
  for (const k of keys) {
    const cur = masked[k];
    const ph = cur ? `current ${cur} — leave blank to keep` : "required";
    form.insertAdjacentHTML("beforeend",
      `<label>${esc(k)}</label>` +
      `<input type="password" data-k="${esc(k)}" placeholder="${esc(ph)}">`);
  }
  const save = document.createElement("div");
  save.className = "env-save";
  const btn = mkBtn("Save keys", "accent", async () => {
    const values = {};
    form.querySelectorAll("input[data-k]").forEach(i => { values[i.dataset.k] = i.value; });
    btn.disabled = true;
    const r = await api().save_env(values);
    btn.disabled = false;
    if (!r.ok) { toast(r.error, true); return; }
    const stillMissing = r.missing && r.missing.length;
    toast(stillMissing ? "Saved — still missing: " + r.missing.join(", ") : "spark.env saved ✓", !!stillMissing);
    state.boot = await api().recheck_env();  // refresh env_ok for Supabase views
    refreshSetup();
  });
  save.appendChild(btn);
  form.appendChild(save);
  return form;
}

async function runSetupFix(id, btn) {
  if (btn) { btn.disabled = true; btn.textContent = "Working…"; }
  const r = await api().fix_setup(id);
  if (!r.ok) {
    toast(r.error, true);
    if (btn) { btn.disabled = false; btn.textContent = "Fix"; }
    return;
  }
  state.setupFixRunning = id;
  toast("Running fix — output is streaming to Logs");
}

$("btnSetupRecheck").onclick = () => {
  $("setupBody").innerHTML = '<div class="empty">Re-checking…</div>';
  refreshSetup();
};

$("btnSetupReport").onclick = async () => {
  const res = setupLast || await refreshSetup();
  const mark = { ok: "[ OK ]", warn: "[WARN]", fail: "[FAIL]" };
  const lines = ["Spark Control — setup report", ""];
  for (const c of res.checks) {
    lines.push(`${mark[c.status] || "[ ?? ]"} ${c.name}${c.optional ? " (optional)" : ""}`);
    if (c.detail) lines.push(`         ${c.detail}`);
  }
  lines.push("");
  lines.push("All required checks passed: " + (res.all_required_ok ? "yes" : "NO"));
  const r = await api().copy_text(lines.join("\n"));
  toast(r.ok ? "Setup report copied to clipboard" : "Couldn't copy report", !r.ok);
};

/* ---------------- live phone preview ----------------
   Renders WDA's MJPEG stream (http://localhost:9100) natively in an <img>: the
   browser decodes multipart/x-mixed-replace, so there's no polling loop, no
   base64, and no per-frame bridge call. Frames only flow while a tool is
   running (the scraper owns the WDA session that feeds the stream), so the
   preview is gated on tool.running. Off by default. If the stream can't be
   reached — port 9100 not forwarded, WKWebView ATS, older WDA — the <img>
   fires onerror and we fall back to the paced /screenshot poll. */
const MJPEG_URL = "http://localhost:9100/";
let previewTimer = null, previewBusy = false, previewFails = 0;
let previewMode = "stream";      // "stream" (MJPEG) | "poll" (fallback)
let streamPanelId = null;        // which view currently owns the live <img>

function eachPreview(fn) { document.querySelectorAll(".preview-panel").forEach(fn); }
function activeView() { return document.querySelector(".view.active"); }
function activePreview() {
  const v = activeView();
  return v ? v.querySelector(".preview-panel") : null;
}

function previewOnTab() {
  const v = activeView();
  return !!v && (v.id === "view-adfinder" || v.id === "view-codes" || v.id === "view-poster");
}
function previewShouldRun() {
  const st = state.status;
  return state.previewEnabled && previewOnTab()
    && !!(st && st.tool && st.tool.running) && !!(st && st.wda && st.wda.ok);
}
function previewIdleReason() {
  const st = state.status;
  if (st && st.tool && st.tool.running && !(st.wda && st.wda.ok)) return "Waiting for WebDriverAgent…";
  return "Start a run to see the screen";
}

function setPreviewStatus(txt) {
  eachPreview(p => { const s = p.querySelector(".preview-status"); if (s) s.textContent = txt || ""; });
}
function setPreviewPlaceholder(text) {
  eachPreview(p => {
    p.querySelector(".preview-img").hidden = true;
    const ph = p.querySelector(".preview-placeholder");
    ph.hidden = false; ph.textContent = text;
  });
}
function syncPreviewUI() {
  eachPreview(p => { p.querySelector(".preview-enable").checked = state.previewEnabled; });
}

/* ----- MJPEG stream ----- */
function stopStream() {
  eachPreview(p => {
    const img = p.querySelector(".preview-img");
    img.onerror = null; img.onload = null;
    if (img.src) img.removeAttribute("src");   // close the multipart connection
    img.hidden = true;
  });
  streamPanelId = null;
}
function onStreamError() {
  if (previewMode !== "stream") return;        // already switched
  previewMode = "poll";
  stopStream();
  setPreviewStatus("Stream unavailable — polling frames");
  if (previewShouldRun()) startPoll();
}
function startStream() {
  const panel = activePreview();
  const view = activeView();
  if (!panel || !view) return;
  // Move the stream to the active tab; drop it from any other panel.
  if (streamPanelId === view.id) return;       // already streaming here
  stopStream();
  const img = panel.querySelector(".preview-img");
  img.onerror = onStreamError;
  img.onload = () => setPreviewStatus("Streaming live");
  panel.querySelector(".preview-placeholder").hidden = true;
  img.hidden = false;
  img.src = MJPEG_URL;
  streamPanelId = view.id;
  setPreviewStatus("Streaming live");
}

/* ----- /screenshot poll fallback ----- */
function setPreviewFrame(frame) {
  const panel = activePreview();
  if (!panel) return;
  const img = panel.querySelector(".preview-img");
  img.src = frame; img.hidden = false;
  panel.querySelector(".preview-placeholder").hidden = true;
  state.previewLastFrameAt = Date.now();
  updatePreviewTs();
}
function updatePreviewTs() {
  if (previewMode !== "poll" || !state.previewLastFrameAt) return;
  const age = Math.floor((Date.now() - state.previewLastFrameAt) / 1000);
  const t = new Date(state.previewLastFrameAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
  setPreviewStatus(`polling · last frame ${t} · ${age}s ago`);
}
function startPoll() {
  stopPollTimer();
  if (!previewShouldRun()) return;
  previewFails = 0;
  pollTick();
  previewTimer = setInterval(pollTick, state.previewRate * 1000);
}
function stopPollTimer() {
  if (previewTimer) { clearInterval(previewTimer); previewTimer = null; }
}
async function pollTick() {
  if (!previewShouldRun()) { stopPollTimer(); return; }
  if (previewBusy) return;                       // skip this frame — don't queue
  previewBusy = true;
  let r;
  try { r = await api().capture_screenshot(); } catch { r = { ok: false }; }
  previewBusy = false;
  if (r && r.ok && r.frame) {
    previewFails = 0;
    setPreviewFrame(r.frame);
  } else if (++previewFails >= 3) {              // back off — stop hammering WDA
    state.previewEnabled = false;
    api().set_preview_prefs(false, state.previewRate);
    syncPreviewUI();
    stopPreview();
    setPreviewStatus("");
    setPreviewPlaceholder("Preview paused — WDA busy");
  }
}

/* ----- shared start/stop ----- */
function startPreview() {
  if (previewMode === "poll") startPoll();
  else startStream();
}
function stopPreview() {
  stopPollTimer();
  stopStream();
}

// Start/stop as tab visibility or tool state changes.
function updatePreviewRunState() {
  if (previewShouldRun()) {
    startPreview();                              // startStream/startPoll self-guard
  } else {
    stopPreview();
    previewMode = "stream";                      // retry the stream on the next run
    setPreviewStatus("");
    if (state.previewEnabled) setPreviewPlaceholder(previewIdleReason());
  }
}

async function loadPreviewPrefs() {
  let p;
  try { p = await api().get_preview_prefs(); } catch { return; }
  if (!p || !p.ok) return;
  state.previewEnabled = !!p.enabled;
  if ([1, 2, 5].includes(p.rate)) state.previewRate = p.rate;
  syncPreviewUI();
  if (!state.previewEnabled) { setPreviewStatus(""); setPreviewPlaceholder("Preview off"); }
  else updatePreviewRunState();
}

document.addEventListener("change", (e) => {
  if (!e.target.classList.contains("preview-enable")) return;
  state.previewEnabled = e.target.checked;
  syncPreviewUI();
  api().set_preview_prefs(state.previewEnabled, state.previewRate);
  if (state.previewEnabled) {
    previewMode = "stream";
    setPreviewStatus("");
    setPreviewPlaceholder("Starting…");
    updatePreviewRunState();
    if (!previewShouldRun()) setPreviewPlaceholder(previewIdleReason());
  } else {
    stopPreview();
    setPreviewStatus("");
    setPreviewPlaceholder("Preview off");
  }
});

/* ---------------- proxy ---------------- */

async function loadProxy() {
  const res = await api().get_proxies();
  if (!res || !res.ok) { toast((res && res.error) || "Couldn't load proxies", true); return; }
  state.proxies = res.proxies || [];
  state.proxySelectedId = res.selected_id || null;
  renderProxies();
  loadProxyServer();
}

async function renderProxyServer(s) {
  if (!s || !s.ok) return;
  state.proxyRouting = s.routing || "tiktok";
  $("proxyRouting").querySelectorAll(".seg-btn")
    .forEach(b => b.classList.toggle("active", b.dataset.routing === state.proxyRouting));

  const dot = $("proxySrvDot"), txt = $("proxySrvText");
  if (s.running && s.port) { dot.className = "srv-dot ok"; txt.textContent = "Running on port " + s.port; }
  else { dot.className = "srv-dot bad"; txt.textContent = s.error ? "Port unavailable" : "Stopped"; }
  $("proxySrvErr").hidden = !s.error;
  if (s.error) $("proxySrvErr").textContent = s.error;

  const ipField = $("proxyLanIp");
  if (document.activeElement !== ipField) ipField.value = s.lan_ip_override || s.lan_ip_detected || "";

  $("proxyUrl").value = s.url || "(no LAN IP detected — set one below)";
  const qrImg = $("proxyQr");
  if (s.url) {
    const q = await api().proxy_qr(s.url);
    if (q.ok) { qrImg.src = q.data_uri; qrImg.style.visibility = "visible"; }
    else qrImg.style.visibility = "hidden";
  } else qrImg.style.visibility = "hidden";
}
async function loadProxyServer() { renderProxyServer(await api().get_proxy_server()); }

$("btnProxyCopy").onclick = async () => {
  const r = await api().copy_text($("proxyUrl").value);
  toast(r.ok ? "URL copied to clipboard" : "Copy failed", !r.ok);
};
$("btnProxyRestart").onclick = async () => {
  renderProxyServer(await api().restart_proxy_server());
  toast("Subscription server restarted");
};
$("proxyLanIp").addEventListener("change", async () => {
  renderProxyServer(await api().set_lan_ip_override($("proxyLanIp").value.trim()));
});

function renderProxies() {
  const tbody = $("proxyTable").querySelector("tbody");
  tbody.innerHTML = state.proxies.map(p => {
    const sel = p.id === state.proxySelectedId;
    const badge = sel ? ' <span class="mini-badge">current</span>' : "";
    return `<tr data-id="${esc(p.id)}">
      <td class="chkcol"><button class="px-star${sel ? " on" : ""}" title="Mark as current node">${sel ? "★" : "☆"}</button></td>
      <td>${esc(p.name || "(unnamed)")}${badge}</td>
      <td>${esc((p.type || "socks5").toUpperCase())}</td>
      <td class="mono">${esc(p.host)}:${esc(p.port)}</td>
      <td class="px-actions"><button class="btn small px-edit">Edit</button><button class="btn small danger px-del">Delete</button></td>
    </tr>`;
  }).join("");
  $("proxyEmpty").hidden = state.proxies.length > 0;
}

// Parse a pasted proxy string. Handles socks5://user:pass@host:port,
// host:port:user:pass, and host:port. Returns partial fields or null.
function parseProxyString(s) {
  s = (s || "").trim();
  if (!s) return null;
  const url = s.match(/^(socks5?|https?):\/\/(?:([^:@]+):([^@]*)@)?([^:/@]+):(\d+)/i);
  if (url) {
    return { type: /socks/i.test(url[1]) ? "socks5" : "http",
             username: url[2] || "", password: url[3] || "", host: url[4], port: url[5] };
  }
  const parts = s.split(":");
  if (parts.length >= 4) {
    return { host: parts[0], port: parts[1], username: parts[2], password: parts.slice(3).join(":") };
  }
  if (parts.length === 2 && /^\d+$/.test(parts[1])) {
    return { host: parts[0], port: parts[1] };
  }
  return null;
}

function applyProxyImport() {
  const parsed = parseProxyString($("pxImport").value);
  if (!parsed) return;
  if (parsed.type) $("pxType").value = parsed.type;
  if (parsed.host) $("pxHost").value = parsed.host;
  if (parsed.port) $("pxPort").value = parsed.port;
  if (parsed.username != null) $("pxUser").value = parsed.username;
  if (parsed.password != null) $("pxPass").value = parsed.password;
}

function openProxyDialog(id) {
  state.proxyEditId = id || null;
  const p = id ? state.proxies.find(x => x.id === id) : null;
  $("proxyDialogTitle").textContent = p ? "Edit Proxy" : "Add Proxy";
  $("pxImport").value = "";
  $("pxName").value = p ? (p.name || "") : "";
  $("pxType").value = p ? (p.type || "socks5") : "socks5";
  $("pxHost").value = p ? (p.host || "") : "";
  $("pxPort").value = p ? (p.port || "") : "";
  $("pxUser").value = p ? (p.username || "") : "";
  $("pxPass").value = p ? (p.password || "") : "";
  $("proxyDialog").hidden = false;
  $("pxImport").focus();
}
function closeProxyDialog() { $("proxyDialog").hidden = true; state.proxyEditId = null; }

async function saveProxyList() {
  const r = await api().save_proxies(state.proxies, state.proxySelectedId);
  if (!r.ok) { toast(r.error, true); return false; }
  state.proxies = r.proxies;      // normalized by the backend
  return true;
}

async function deleteProxy(id) {
  const p = state.proxies.find(x => x.id === id);
  if (!confirm(`Delete proxy "${p ? (p.name || p.host) : "this proxy"}"?`)) return;
  state.proxies = state.proxies.filter(x => x.id !== id);
  if (state.proxySelectedId === id) state.proxySelectedId = null;
  if (await saveProxyList()) { renderProxies(); toast("Proxy deleted"); }
}

async function selectProxy(id) {
  state.proxySelectedId = (state.proxySelectedId === id) ? null : id;
  await api().set_selected_proxy(state.proxySelectedId);
  renderProxies();
}

$("btnProxyAdd").onclick = () => openProxyDialog(null);
$("pxImport").addEventListener("blur", applyProxyImport);
$("pxImport").addEventListener("paste", () => setTimeout(applyProxyImport, 0));
$("pxCancel").onclick = closeProxyDialog;
$("proxyDialogClose").onclick = closeProxyDialog;
$("proxyDialog").addEventListener("click", (e) => { if (e.target === $("proxyDialog")) closeProxyDialog(); });

$("pxSave").onclick = async () => {
  applyProxyImport();  // catch a paste the user didn't blur out of
  const host = $("pxHost").value.trim(), port = $("pxPort").value.trim();
  if (!host || !port) { toast("Host and port are required", true); return; }
  const proxy = {
    id: state.proxyEditId || ("p" + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36)),
    name: $("pxName").value.trim(),
    type: $("pxType").value,
    host, port: +port,
    username: $("pxUser").value,
    password: $("pxPass").value,
  };
  if (state.proxyEditId) state.proxies = state.proxies.map(x => x.id === state.proxyEditId ? proxy : x);
  else state.proxies.push(proxy);
  if (await saveProxyList()) { closeProxyDialog(); renderProxies(); toast("Proxy saved"); }
};

$("proxyTable").addEventListener("click", (e) => {
  const tr = e.target.closest("tr[data-id]");
  if (!tr) return;
  const id = tr.dataset.id;
  if (e.target.closest(".px-edit")) openProxyDialog(id);
  else if (e.target.closest(".px-del")) deleteProxy(id);
  else if (e.target.closest(".px-star")) selectProxy(id);
});

$("proxyRouting").addEventListener("click", async (e) => {
  const b = e.target.closest(".seg-btn");
  if (!b) return;
  state.proxyRouting = b.dataset.routing;
  $("proxyRouting").querySelectorAll(".seg-btn").forEach(x => x.classList.toggle("active", x === b));
  await api().set_proxy_routing(state.proxyRouting);  // served YAML picks it up immediately
});

$("btnProxyGen").onclick = async () => {
  const r = await api().generate_stash_config({ routing: state.proxyRouting, path: $("proxyConfigPath").value.trim() });
  const msg = $("proxyGenMsg");
  if (!r.ok) {
    msg.textContent = r.error; msg.className = "savemsg err";
    if (r.preview) { $("proxyPreview").textContent = r.preview; $("proxyPreview").hidden = false; }
    $("btnProxyReveal").hidden = true;
    return;
  }
  state.proxyLastPath = r.path;
  $("proxyPreview").textContent = r.preview;
  $("proxyPreview").hidden = false;
  $("btnProxyReveal").hidden = false;
  msg.textContent = `Saved ${r.count} prox${r.count === 1 ? "y" : "ies"} → ${r.path}`;
  msg.className = "savemsg";
};

$("btnProxyReveal").onclick = async () => {
  const r = await api().reveal_path(state.proxyLastPath || $("proxyConfigPath").value.trim());
  if (!r.ok) toast(r.error, true);
};

/* ---------------- signing ---------------- */

const SIGN_BADGE_TEXT = {
  ok:      (s) => `Valid — ${s.days_remaining} day${s.days_remaining === 1 ? "" : "s"} left`,
  warn:    (s) => `Expires soon — ${s.days_remaining} day${s.days_remaining === 1 ? "" : "s"} left`,
  expired: () => "Expired — re-sign to rebuild",
  none:    () => "No WebDriverAgent profile found",
};

async function loadSigning() {
  const badge = $("signBadge"), facts = $("signFacts");
  badge.className = "sign-badge state-none";
  badge.textContent = "Checking…";
  facts.innerHTML = "";

  let s;
  try { s = await api().get_signing(); }
  catch { badge.textContent = "Couldn't read signing state"; return; }
  if (!s || !s.ok) {
    badge.textContent = (s && s.error) || "Couldn't read signing state";
    return;
  }

  const st = s.state || "none";
  badge.className = "sign-badge state-" + st;
  badge.textContent = (SIGN_BADGE_TEXT[st] || SIGN_BADGE_TEXT.none)(s);

  const noteEl = $("signNote");
  if (s.note) { noteEl.textContent = s.note; noteEl.hidden = false; }
  else noteEl.hidden = true;

  const rows = [];
  if (s.team) rows.push(["Team", s.team, false]);
  if (s.profile_name) rows.push(["Profile", s.profile_name, false]);
  if (s.app_id) rows.push(["App ID", s.app_id, true]);
  if (s.expiry_iso) rows.push(["Expires", fmtDateYear(s.expiry_iso), false]);
  rows.push(["Identities", s.identities.length ? s.identities.join(", ") : "none found", false]);
  facts.innerHTML = rows.map(([k, v, mono]) =>
    `<div class="k">${esc(k)}</div><div class="v${mono ? " mono" : ""}">${esc(v)}</div>`).join("");

  const warn = $("signIdWarn");
  if (!s.identities_ok || !s.identities.length) {
    warn.textContent = "No code-signing identities found. Open Xcode → Settings → "
      + "Accounts and sign in with your Apple ID so a certificate is created.";
    warn.hidden = false;
  } else warn.hidden = true;

  $("signUsing").textContent = `Using team ${s.team || "—"} · device UDID ${s.udid || "—"}`
    + " (both editable in Settings).";

  loadImporter();   // refresh the photo-importer card alongside signing
}

async function loadImporter() {
  const stateEl = $("importerState");
  let r;
  try { r = await api().get_importer_status(); }
  catch { stateEl.textContent = "unknown"; return null; }
  if (!r || !r.ok) { stateEl.textContent = "unknown"; return null; }
  const prog = $("importerProgress"), note = $("importerNote");
  if (r.running) {
    stateEl.textContent = "installing…";
    prog.hidden = false; note.hidden = true;
  } else {
    prog.hidden = true;
    if (r.installed) { stateEl.textContent = "installed ✓"; note.hidden = false; }
    else {
      stateEl.textContent = (r.last_exit != null && r.last_exit !== 0)
        ? "install failed — see Logs" : "not installed";
      note.hidden = true;
    }
  }
  return r;
}

let importerTimer = null;
$("btnImporterInstall").onclick = async () => {
  const r = await api().install_importer();
  if (!r.ok) { toast(r.error, true); return; }
  toast("Building & installing SparkImporter… (watch Logs)");
  $("importerProgress").hidden = false;
  $("importerState").textContent = "installing…";
  if (importerTimer) clearInterval(importerTimer);
  importerTimer = setInterval(async () => {
    const s = await loadImporter();
    if (s && !s.running) {
      clearInterval(importerTimer); importerTimer = null;
      if (s.installed) toast("SparkImporter installed. Open it once on the phone and tap Allow.");
      else toast("Install finished but the app isn't detected — check Logs.", true);
    }
  }, 3000);
};

$("btnSignRefresh").onclick = loadSigning;

$("btnSignResign").onclick = async () => {
  const r = await api().resign_wda();
  if (!r.ok) toast(r.error, true);
  else toast(r.note || "Re-signing & rebuilding WebDriverAgent…");
  pollStatus();
};

$("btnSignXcode").onclick = async () => {
  const r = await api().open_wda_xcode();
  toast(r.ok ? "Opening WebDriverAgent in Xcode…" : r.error, !r.ok);
};

$("btnSignCancel").onclick = () => cancelWdaBuild("btnSignCancel");
// (the "Trust the certificate" panel uses the generic .collapse-head handler)

// ".err-link" elements either route to a section (data-goto) or open a URL
// (data-open, e.g. the App Store link in the setup instructions).
document.addEventListener("click", (e) => {
  const link = e.target.closest(".err-link");
  if (!link) return;
  e.preventDefault();
  if (link.dataset.goto) showView(link.dataset.goto);
  else if (link.dataset.open) api().open_url(link.dataset.open);
});

/* ---------------- go ---------------- */

if (window.pywebview && window.pywebview.api) boot();
else window.addEventListener("pywebviewready", boot);
