/* Auto-Bounce by Muffin - frontend */
(function () {
  'use strict';

  const TOKEN = document.querySelector('meta[name="ab-token"]').content;
  const LIMIT = parseInt(document.querySelector('meta[name="ab-limit"]').content, 10) || 3;

  const $ = (id) => document.getElementById(id);
  const els = {
    limitChip: $('limitChip'), limitChipText: $('limitChipText'),
    dropZone: $('dropZone'), dropHint: $('dropHint'),
    flCard: $('flCard'), locateFlBtn: $('locateFlBtn'), flNote: $('flNote'),
    limitPanel: $('limitPanel'), limitMax: $('limitMax'), getMuffinBtn: $('getMuffinBtn'),
    nowCard: $('nowCard'), nowName: $('nowName'), nowFmt: $('nowFmt'),
    nowBar: $('nowBar'), nowPct: $('nowPct'), nowStage: $('nowStage'),
    nowElapsed: $('nowElapsed'), cancelNowBtn: $('cancelNowBtn'),
    queueBlock: $('queueBlock'), queueList: $('queueList'), queueCount: $('queueCount'),
    doneBlock: $('doneBlock'), doneList: $('doneList'), doneCount: $('doneCount'),
    outBtn: $('outBtn'), outLabel: $('outLabel'), outReset: $('outReset'),
    muffinBanner: $('muffinBanner'),
    infoBtn: $('infoBtn'), infoModal: $('infoModal'), infoClose: $('infoClose'),
    settingsBtn: $('settingsBtn'), settingsModal: $('settingsModal'), settingsClose: $('settingsClose'),
    modeSeg: $('modeSeg'), flPathBox: $('flPathBox'), changeFlBtn: $('changeFlBtn'),
    renderSpin: $('renderSpin'),
    startBtn: $('startBtn'), startLabel: $('startLabel'),
    startIcoPlay: $('startIcoPlay'), startIcoStop: $('startIcoStop'),
    modalMascot: $('modalMascot'), modalMascotImg: $('modalMascotImg'),
    modalGetMuffin: $('modalGetMuffin'), lifetimeCount: $('lifetimeCount'),
    mascotBtn: $('mascotBtn'), mascotImg: $('mascotImg'),
    flModal: $('flModal'), flConfirmBtn: $('flConfirmBtn'), flCancelBtn: $('flCancelBtn'),
    confetti: $('confetti'), toast: $('toast'),
  };

  const IDLE_SRC = '/asset/muffin_idle.png';
  const HANDSUP_SRC = '/asset/muffin_handsup.png';
  new Image().src = HANDSUP_SRC; // preload hover pose

  let state = null;
  let seenDone = new Set();
  let firstPoll = true;
  let pendingStart = false; // Start pressed, waiting for FL-close confirmation
  let pollTimer = null;
  let queueDragging = false;
  let lastQueueHtml = null;
  let lastDoneHtml = null;

  // ────────── api ──────────

  async function api(path, body) {
    const opts = {
      method: body === undefined ? 'GET' : 'POST',
      headers: { 'X-AB-Token': TOKEN, 'Content-Type': 'application/json' },
    };
    if (body !== undefined) opts.body = JSON.stringify(body);
    const res = await fetch(path, opts);
    if (!res.ok && res.status !== 400) throw new Error('HTTP ' + res.status);
    return res.json();
  }

  // ────────── mascot hover ──────────

  function wireMascot(btn, img) {
    btn.addEventListener('mouseenter', () => { img.src = HANDSUP_SRC; });
    btn.addEventListener('mouseleave', () => { img.src = IDLE_SRC; });
  }
  wireMascot(els.mascotBtn, els.mascotImg);
  wireMascot(els.modalMascot, els.modalMascotImg);
  wireMascot(els.muffinBanner, els.muffinBanner.querySelector('img'));

  function openMuffinSite() {
    if (window.pywebview && window.pywebview.api && window.pywebview.api.open_muffin_site) {
      window.pywebview.api.open_muffin_site();
    } else {
      api('/api/open_muffin', {}).catch(() => {});
    }
  }
  [els.mascotBtn, els.modalMascot, els.modalGetMuffin, els.getMuffinBtn, els.muffinBanner]
    .forEach((b) => b.addEventListener('click', openMuffinSite));

  // ────────── toast ──────────

  let toastTimer = null;
  function toast(msg, isErr) {
    els.toast.textContent = msg;
    els.toast.classList.toggle('toast-err', !!isErr);
    els.toast.hidden = false;
    clearTimeout(toastTimer);
    toastTimer = setTimeout(() => { els.toast.hidden = true; }, 3400);
  }

  // ────────── confetti + sound ──────────

  const ctx2d = els.confetti.getContext('2d');
  let pieces = [];
  let confettiRaf = null;
  const COLORS = ['#ffe227', '#f5c400', '#63cbe0', '#f58a8a', '#8fdf9a', '#ffffff'];

  function burstConfetti() {
    const W = (els.confetti.width = window.innerWidth);
    const H = (els.confetti.height = window.innerHeight);
    for (let i = 0; i < 90; i++) {
      pieces.push({
        x: W / 2 + (Math.random() - 0.5) * 220,
        y: H * 0.32,
        vx: (Math.random() - 0.5) * 11,
        vy: -(4 + Math.random() * 9),
        w: 5 + Math.random() * 6,
        h: 3 + Math.random() * 4,
        rot: Math.random() * Math.PI,
        vr: (Math.random() - 0.5) * 0.3,
        color: COLORS[(Math.random() * COLORS.length) | 0],
        life: 90 + Math.random() * 40,
      });
    }
    if (!confettiRaf) tickConfetti();
  }

  function tickConfetti() {
    const W = els.confetti.width, H = els.confetti.height;
    ctx2d.clearRect(0, 0, W, H);
    pieces = pieces.filter((p) => p.life > 0 && p.y < H + 30);
    for (const p of pieces) {
      p.x += p.vx; p.y += p.vy; p.vy += 0.32; p.vx *= 0.985;
      p.rot += p.vr; p.life -= 1;
      ctx2d.save();
      ctx2d.translate(p.x, p.y);
      ctx2d.rotate(p.rot);
      ctx2d.globalAlpha = Math.min(1, p.life / 30);
      ctx2d.fillStyle = p.color;
      ctx2d.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
      ctx2d.restore();
    }
    if (pieces.length) {
      confettiRaf = requestAnimationFrame(tickConfetti);
    } else {
      confettiRaf = null;
      ctx2d.clearRect(0, 0, W, H);
    }
  }

  let audioCtx = null;
  function ding(success) {
    try {
      audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
      const t0 = audioCtx.currentTime;
      const notes = success ? [[659.25, 0], [987.77, 0.09]] : [[220, 0], [174.6, 0.12]];
      for (const [freq, dt] of notes) {
        const osc = audioCtx.createOscillator();
        const gain = audioCtx.createGain();
        osc.type = 'triangle';
        osc.frequency.value = freq;
        gain.gain.setValueAtTime(0.0001, t0 + dt);
        gain.gain.exponentialRampToValueAtTime(0.12, t0 + dt + 0.02);
        gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dt + 0.35);
        osc.connect(gain).connect(audioCtx.destination);
        osc.start(t0 + dt);
        osc.stop(t0 + dt + 0.4);
      }
    } catch (e) { /* sound is a bonus, never an error */ }
  }

  function celebrate() {
    burstConfetti();
    ding(true);
    els.mascotImg.src = HANDSUP_SRC;
    els.mascotBtn.classList.add('celebrating');
    setTimeout(() => {
      els.mascotBtn.classList.remove('celebrating');
      els.mascotImg.src = IDLE_SRC;
    }, 1300);
  }

  // ────────── queueing ──────────

  async function queuePaths(paths) {
    if (!paths || !paths.length) return;
    let res;
    try {
      res = await api('/api/queue', { paths });
    } catch (e) {
      toast('Could not reach the Auto-Bounce engine', true);
      return;
    }
    const queued = (res && res.queued) || [];
    const rejected = (res && res.rejected) || [];
    if (queued.length) {
      const paused = !state || !state.status || state.status.paused !== false;
      const hint = paused && !(state && state.status && state.status.current) ? '. Press Start when ready' : '';
      toast((queued.length === 1
        ? 'Queued: ' + queued[0].name
        : 'Queued ' + queued.length + ' projects') + hint);
    }
    const limitHit = rejected.filter((r) => r.reason === 'limit');
    const invalid = rejected.filter((r) => r.reason !== 'limit');
    if (invalid.length) toast(invalid[0].reason, true);
    else if (limitHit.length) toast('Daily free limit reached. Muffin has no limits!', true);
    poll();
  }

  // ────────── start / stop ──────────

  async function startQueue(confirmed) {
    const res = await api('/api/start', { confirm_close_fl: !!confirmed });
    if (res && res.ok === false && res.error === 'fl_running') {
      pendingStart = true;
      els.flModal.hidden = false;
      return;
    }
    poll();
  }

  els.startBtn.addEventListener('click', async () => {
    if (els.startBtn.classList.contains('btn-stop')) {
      await api('/api/stop', {});
      toast('Stopped. Your queue is paused.');
      poll();
    } else {
      startQueue(false);
    }
  });

  els.flConfirmBtn.addEventListener('click', () => {
    els.flModal.hidden = true;
    if (pendingStart) { pendingStart = false; startQueue(true); }
  });
  els.flCancelBtn.addEventListener('click', () => {
    els.flModal.hidden = true;
    pendingStart = false;
  });

  // ────────── drag and drop ──────────

  // Charging animation: fill the A + border ring + % while a file hovers the zone.
  let chargeTimer = null;
  function startCharge() {
    if (chargeTimer) return;
    const dz = els.dropZone;
    const ring = dz.querySelector('.drop-ring-rect');
    const lvl = $('dropFillLvl');
    const pctEl = document.querySelector('#dropPct b');
    const spark = $('dropSpark');
    // Build a rounded-rect offset-path from the zone's real pixel size so the
    // spark hugs the border no matter how wide the window is.
    if (spark) {
      const w = dz.clientWidth, h = dz.clientHeight, r = 18, p = 8;
      const path = `M ${p + r} ${p} H ${w - p - r} A ${r} ${r} 0 0 1 ${w - p} ${p + r} `
        + `V ${h - p - r} A ${r} ${r} 0 0 1 ${w - p - r} ${h - p} `
        + `H ${p + r} A ${r} ${r} 0 0 1 ${p} ${h - p - r} `
        + `V ${p + r} A ${r} ${r} 0 0 1 ${p + r} ${p} Z`;
      spark.style.offsetPath = `path('${path}')`;
      spark.style.webkitOffsetPath = `path('${path}')`;
    }
    let v = 0;
    chargeTimer = setInterval(() => {
      v += 3; if (v > 100) v = 0;
      if (ring) ring.style.strokeDashoffset = 1960 - (v / 100 * 1960);
      if (lvl) lvl.style.clipPath = `inset(${100 - v}% 0 0 0)`;
      if (pctEl) pctEl.textContent = Math.round(v) + '%';
    }, 40);
  }
  function stopCharge() {
    if (chargeTimer) { clearInterval(chargeTimer); chargeTimer = null; }
    const ring = els.dropZone.querySelector('.drop-ring-rect');
    const lvl = $('dropFillLvl');
    const pctEl = document.querySelector('#dropPct b');
    if (ring) ring.style.strokeDashoffset = 1960;
    if (lvl) lvl.style.clipPath = 'inset(100% 0 0 0)';
    if (pctEl) pctEl.textContent = '0%';
  }

  ['dragenter', 'dragover'].forEach((ev) =>
    document.addEventListener(ev, (e) => {
      e.preventDefault();
      els.dropZone.classList.add('dragging');
      startCharge();
    }));
  ['dragleave', 'drop'].forEach((ev) =>
    document.addEventListener(ev, (e) => {
      e.preventDefault();
      if (ev === 'dragleave' && e.relatedTarget) return;
      els.dropZone.classList.remove('dragging');
      stopCharge();
    }));

  document.addEventListener('drop', (e) => {
    e.preventDefault();
    const files = Array.from((e.dataTransfer && e.dataTransfer.files) || []);
    const paths = files.map((f) => f.pywebviewFullPath || f.path || null).filter(Boolean);
    if (paths.length) {
      queuePaths(paths.filter((p) => p.toLowerCase().endsWith('.flp')));
      const skipped = paths.filter((p) => !p.toLowerCase().endsWith('.flp'));
      if (skipped.length) toast('Skipped ' + skipped.length + ' non .flp file(s)', true);
    } else if (files.length) {
      toast('Could not read the file path. Use click to browse instead.', true);
    }
  });

  async function browse() {
    if (window.pywebview && window.pywebview.api && window.pywebview.api.pick_files) {
      const paths = await window.pywebview.api.pick_files();
      if (paths && paths.length) queuePaths(paths);
    } else {
      const manual = prompt('Full path to your .flp project:');
      if (manual) queuePaths([manual.trim()]);
    }
  }
  els.dropZone.addEventListener('click', browse);
  els.dropZone.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); browse(); }
  });

  // ────────── settings ──────────

  els.outBtn.addEventListener('click', async (e) => {
    if (e.target === els.outReset) return;
    if (window.pywebview && window.pywebview.api && window.pywebview.api.pick_folder) {
      const folder = await window.pywebview.api.pick_folder();
      if (folder) {
        const res = await api('/api/config', { output_dir: folder });
        if (res.ok) renderConfig(res.config);
        else toast(res.error || 'Could not set folder', true);
      }
    } else {
      const manual = prompt('Output folder (leave empty for: next to project):', '');
      if (manual !== null) {
        const res = await api('/api/config', { output_dir: manual.trim() });
        if (res.ok) renderConfig(res.config); else toast(res.error || 'Could not set folder', true);
      }
    }
  });

  els.outReset.addEventListener('click', async (e) => {
    e.stopPropagation();
    const res = await api('/api/config', { output_dir: '' });
    if (res.ok) renderConfig(res.config);
  });

  async function locateFl() {
    if (window.pywebview && window.pywebview.api && window.pywebview.api.pick_fl_exe) {
      const exe = await window.pywebview.api.pick_fl_exe();
      if (exe) {
        const res = await api('/api/config', { fl_studio_path: exe });
        if (res.ok) { toast('FL Studio linked!'); poll(); }
        else toast(res.error || 'Could not use that file', true);
      }
    } else {
      const manual = prompt('Full path to FL64.exe:');
      if (manual) {
        const res = await api('/api/config', { fl_studio_path: manual.trim() });
        if (res.ok) { toast('FL Studio linked!'); poll(); }
        else toast(res.error || 'Could not use that file', true);
      }
    }
  }
  els.locateFlBtn.addEventListener('click', locateFl);
  els.changeFlBtn.addEventListener('click', locateFl);

  els.modeSeg.addEventListener('click', async (e) => {
    const btn = e.target.closest('.seg-btn');
    if (!btn) return;
    const res = await api('/api/config', { render_mode: btn.dataset.mode });
    if (res.ok) renderConfig(res.config);
  });

  // ────────── modals ──────────

  els.infoBtn.addEventListener('click', () => { els.infoModal.hidden = false; });
  els.infoClose.addEventListener('click', () => { els.infoModal.hidden = true; });
  els.infoModal.addEventListener('click', (e) => {
    if (e.target === els.infoModal) els.infoModal.hidden = true;
  });
  els.settingsBtn.addEventListener('click', () => { els.settingsModal.hidden = false; });
  els.settingsClose.addEventListener('click', () => { els.settingsModal.hidden = true; });
  els.settingsModal.addEventListener('click', (e) => {
    if (e.target === els.settingsModal) els.settingsModal.hidden = true;
  });
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') { els.infoModal.hidden = true; els.flModal.hidden = true; els.settingsModal.hidden = true; }
  });

  // ────────── queue drag reorder ──────────

  function rowAfterY(y) {
    const rows = [...els.queueList.querySelectorAll('.job-row:not(.drag-src)')];
    return rows.find((r) => {
      const box = r.getBoundingClientRect();
      return y <= box.top + box.height / 2;
    }) || null;
  }

  // Per-project format toggle (delegated; lives inside the draggable rows).
  els.queueList.addEventListener('click', async (e) => {
    const btn = e.target.closest('.qfmt .seg-btn');
    if (!btn) return;
    const seg = btn.closest('.qfmt');
    for (const b of seg.querySelectorAll('.seg-btn')) b.classList.toggle('active', b === btn);
    lastQueueHtml = null; // let the next poll rebuild from server truth
    try {
      await api('/api/job_format', { job_id: btn.dataset.id, format: btn.dataset.fmt });
    } catch (err) {
      toast('Could not change the format', true);
    }
    poll();
  });

  els.queueList.addEventListener('dragstart', (e) => {
    // Do not start a drag from the format toggle - it is a click target.
    if (e.target.closest('.qfmt')) { e.preventDefault(); return; }
    const row = e.target.closest('.job-row');
    if (!row) return;
    queueDragging = true;
    row.classList.add('drag-src');
    e.dataTransfer.effectAllowed = 'move';
    try { e.dataTransfer.setData('text/plain', row.dataset.id); } catch (err) { /* older engines */ }
  });

  els.queueList.addEventListener('dragover', (e) => {
    if (!queueDragging) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    const src = els.queueList.querySelector('.drag-src');
    if (!src) return;
    const after = rowAfterY(e.clientY);
    if (after == null) els.queueList.appendChild(src);
    else if (after !== src) els.queueList.insertBefore(src, after);
  });

  els.queueList.addEventListener('drop', (e) => { e.preventDefault(); });

  els.queueList.addEventListener('dragend', async () => {
    const src = els.queueList.querySelector('.drag-src');
    if (src) src.classList.remove('drag-src');
    if (!queueDragging) return;
    queueDragging = false;
    const ids = [...els.queueList.querySelectorAll('.job-row')].map((r) => r.dataset.id).filter(Boolean);
    lastQueueHtml = null; // force a rebuild on the next poll (renumbers badges)
    try {
      await api('/api/reorder', { job_ids: ids });
    } catch (err) {
      toast('Could not save the new order', true);
    }
    poll();
  });

  // ────────── cancel / retry / reveal ──────────

  els.cancelNowBtn.addEventListener('click', async () => {
    if (state && state.status.current) {
      await api('/api/cancel', { job_id: state.status.current.id });
      toast('Cancelling render');
      poll();
    }
  });

  window.abCancelJob = async (id) => { await api('/api/cancel', { job_id: id }); poll(); };
  window.abRetryJob = (path) => { queuePaths([path]); };
  window.abReveal = async (path) => {
    const res = await api('/api/open_folder', { path });
    if (!res.ok) toast(res.error || 'File not found', true);
  };

  // ────────── rendering ──────────

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

  function fmtElapsed(sec) {
    if (sec == null || !isFinite(sec)) return '';
    sec = Math.max(0, Math.round(sec));
    const m = Math.floor(sec / 60), s = sec % 60;
    return m + ':' + String(s).padStart(2, '0');
  }

  function renderConfig(cfg) {
    const out = cfg.output_dir;
    els.outLabel.textContent = out ? out : 'Next to project';
    els.outLabel.title = out || '';
    els.outReset.hidden = !out;
    const mode = cfg.render_mode || 'auto';
    for (const btn of els.modeSeg.querySelectorAll('.seg-btn')) {
      btn.classList.toggle('active', btn.dataset.mode === mode);
    }
  }

  function render() {
    if (!state) return;
    const st = state.status, lim = state.limit;

    // limit chip
    els.limitChipText.textContent = lim.left + ' of ' + lim.max + ' free today';
    els.limitChip.classList.toggle('chip-empty', lim.left <= 0);

    // fl card
    els.flCard.hidden = !!st.fl_exe;
    els.flNote.hidden = !st.fl_exe;
    if (st.fl_exe) {
      els.flNote.textContent = 'FL Studio: ' + st.fl_exe;
    }

    // drop zone vs limit panel
    const limitExhausted = lim.left <= 0 && !st.current && st.queue.length === 0;
    els.limitPanel.hidden = !limitExhausted;
    els.limitMax.textContent = lim.max;
    els.dropZone.hidden = limitExhausted;
    els.dropZone.classList.toggle('zone-disabled', !st.fl_exe);
    els.dropHint.textContent = lim.left > 0 && lim.left < lim.max
      ? lim.left + ' free bounce' + (lim.left === 1 ? '' : 's') + ' left today'
      : '';

    // render spinner (blue circle above the footer, only while working)
    els.renderSpin.hidden = !st.current;

    // FL path in settings
    els.flPathBox.textContent = st.fl_exe || 'Auto-detect (FL Studio not found yet)';
    els.flPathBox.title = st.fl_exe || '';

    // current
    els.nowCard.hidden = !st.current;
    if (st.current) {
      const c = st.current;
      els.nowName.textContent = c.name;
      els.nowFmt.textContent = (c.format || 'mp3').toUpperCase();
      els.nowBar.style.width = (c.percent || 0) + '%';
      els.nowPct.textContent = (c.percent || 0) + '%';
      els.nowStage.textContent = c.stage || 'Working';
      els.nowElapsed.textContent = c.started_at ? fmtElapsed(Date.now() / 1000 - c.started_at) : '';
    }

    // start / stop button
    const hasWork = !!st.current || st.queue.length > 0;
    const stopping = !!st.current || st.paused === false;
    els.startBtn.hidden = !hasWork;
    els.startBtn.classList.toggle('btn-stop', stopping);
    els.startLabel.textContent = stopping
      ? 'Stop'
      : 'Start' + (st.queue.length > 1 ? ' (' + st.queue.length + ')' : '');
    els.startIcoPlay.hidden = stopping;
    els.startIcoStop.hidden = !stopping;

    // queue (skip DOM rebuilds while the user is dragging rows, and only
    // touch innerHTML when the content really changed so the row-in
    // animation cannot replay on every poll)
    els.queueBlock.hidden = st.queue.length === 0;
    els.queueCount.textContent = st.queue.length;
    const queueHtml = st.queue.map((j, i) => {
      const fmt = (j.format || 'mp3').toLowerCase();
      return '<div class="job-row job-row-drag" draggable="true" data-id="' + j.id + '">' +
      '<span class="drag-grip" title="Drag to reorder" aria-hidden="true">&#8942;&#8942;</span>' +
      '<div class="job-badge b-wait">' + (i + 1) + '</div>' +
      '<div class="job-name" title="' + esc(j.project_path) + '">' + esc(j.name) + '</div>' +
      '<div class="seg seg-mini qfmt" title="Output format for this project">' +
        '<button class="seg-btn' + (fmt === 'mp3' ? ' active' : '') + '" data-fmt="mp3" data-id="' + j.id + '" draggable="false">MP3</button>' +
        '<button class="seg-btn' + (fmt === 'wav' ? ' active' : '') + '" data-fmt="wav" data-id="' + j.id + '" draggable="false">WAV</button>' +
      '</div>' +
      '<div class="job-actions"><button class="icon-btn icon-btn-sm" title="Remove from queue" onclick="abCancelJob(\'' + j.id + '\')">' +
      '<svg viewBox="0 0 24 24"><path d="M6 6 18 18 M18 6 6 18" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/></svg>' +
      '</button></div></div>';
    }).join('');
    if (!queueDragging && queueHtml !== lastQueueHtml) {
      lastQueueHtml = queueHtml;
      els.queueList.innerHTML = queueHtml;
    }

    // history
    const hist = st.history || [];
    els.doneBlock.hidden = hist.length === 0;
    els.doneCount.textContent = hist.filter((j) => j.status === 'done').length;
    const doneHtml = hist.map((j) => {
      const done = j.status === 'done';
      const failed = j.status === 'failed';
      const badge = done ? '<div class="job-badge b-done">&#10003;</div>'
        : failed ? '<div class="job-badge b-fail">!</div>'
        : '<div class="job-badge b-cancel">&#8722;</div>';
      let meta = '';
      if (done) meta = esc((j.format || '').toUpperCase()) + (j.duration_seconds ? ' &middot; ' + fmtElapsed(j.duration_seconds) : '');
      else if (failed) meta = '<span class="job-err" title="' + esc(j.error || '') + '">' + esc(shortErr(j.error)) + '</span>';
      else meta = 'Cancelled';
      let actions = '';
      if (done && j.output_path) {
        actions += '<button class="btn btn-secondary btn-sm btn-folder" title="Open the folder with this file selected" onclick="abReveal(' + jsStr(j.output_path) + ')">' +
          '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="2.2"/></svg>' +
          'Show in folder</button>';
      }
      if (failed || j.status === 'cancelled') {
        actions += '<button class="btn btn-secondary btn-sm" onclick="abRetryJob(' + jsStr(j.project_path) + ')">Retry</button>';
      }
      return '<div class="job-row">' + badge +
        '<div class="job-name" title="' + esc(j.project_path) + '">' + esc(j.name) + '</div>' +
        '<div class="job-meta">' + meta + '</div>' +
        '<div class="job-actions">' + actions + '</div></div>';
    }).join('');
    if (doneHtml !== lastDoneHtml) {
      lastDoneHtml = doneHtml;
      els.doneList.innerHTML = doneHtml;
    }

    // info modal bits
    els.lifetimeCount.textContent = lim.lifetime;

    renderConfig(state.config);
  }

  function shortErr(err) {
    err = String(err || 'Failed');
    return err.length > 46 ? err.slice(0, 44) + '…' : err;
  }

  function jsStr(s) {
    return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
  }

  // ────────── poll loop ──────────

  async function poll() {
    let data;
    try {
      data = await api('/api/state');
    } catch (e) {
      schedule(3000);
      return;
    }
    state = data;

    // celebrate newly finished renders (skip everything present on first poll)
    for (const j of data.status.history || []) {
      if (j.status === 'done' && !seenDone.has(j.id)) {
        seenDone.add(j.id);
        if (!firstPoll) celebrate();
      } else if (j.id) {
        seenDone.add(j.id);
      }
    }
    if (!firstPoll) {
      for (const j of data.status.history || []) {
        if (j.status === 'failed' && !seenFailed.has(j.id)) { seenFailed.add(j.id); ding(false); }
        else if (j.id) seenFailed.add(j.id);
      }
    } else {
      (data.status.history || []).forEach((j) => j.id && seenFailed.add(j.id));
    }
    firstPoll = false;

    render();
    const active = data.status.current || data.status.queue.length > 0;
    schedule(active ? 900 : 2600);
  }

  const seenFailed = new Set();

  function schedule(ms) {
    clearTimeout(pollTimer);
    pollTimer = setTimeout(poll, ms);
  }

  window.addEventListener('resize', () => {
    els.confetti.width = window.innerWidth;
    els.confetti.height = window.innerHeight;
  });

  poll();
})();
