// components/updater/js/updater-page.js — App Updater controller.
//
// Surfaces, per installed app: version state (current -> available), security
// posture (CVEs by severity), and disaster-recovery readiness (a snapshot is
// taken before every update so any update is reversible). Read-only data comes
// from /data/updater/generated/*.json (written host-side by the updater
// generator); every action is dispatched through the task system via
// services.tasks.route(...) — the same locked-down mutation path apps/backups
// use. No mutating API is added.
class UpdaterPage {
  constructor(services) {
    this.services = services || (window.LP && window.LP.services) || {};
    this.currentTab = 'overview';
    this.updates = null;     // { generated_at, apps: [...] }
    this.cves = null;        // { generated_at, apps: [...], totals: {...} }
    this.history = null;     // { entries: [...] }
    this.artifacts = null;   // { signed, serial, artifacts: [...] }  (hotfixes)
    this.apps = [];          // merged per-app view rendered in the table
    this._pushedAnyTab = false;
    this._eventBound = false;
    this._poll = null;       // auto-refresh timer (startAutoRefresh/dispose)
  }

  // ---- lifecycle -----------------------------------------------------------

  async init() {
    this.currentTab = this.parseTabFromUrl() || this.currentTab;
    this.applyActiveTabUi(this.currentTab);
    this.bindEvents();
    await this.refreshAll();
    this.render();
    this.updateHeader();
    // Keep the open page in step with the host-side auto-scan (the task
    // processor refreshes the generated JSON on its own schedule, with no task
    // event to hook). Repaints only when the data actually changed.
    this.startAutoRefresh(
      () => this.render(),
      () => window.updaterPage === this && !!document.getElementById('updater-page')
    );
  }

  // The host auto-scan (`libreportal updater check auto`, run from the task
  // processor's idle poll) rewrites the generated JSON without a task event,
  // so an open page re-reads it on a slow timer: a few static-file GETs per
  // minute, and onChange() only when a generated_at stamp moved. isActive lets
  // the hosting surface skip ticks while it isn't visible; dispose() releases
  // the timer (also called by the feature unmount).
  startAutoRefresh(onChange, isActive) {
    if (this._poll) return;
    this._poll = setInterval(() => {
      if (document.hidden || (isActive && !isActive())) return;
      const before = this._dataStamp();
      this.refreshAll().then(() => {
        if (this._poll && this._dataStamp() !== before && onChange) onChange();
      });
    }, 60000);
  }

  dispose() {
    if (this._poll) { clearInterval(this._poll); this._poll = null; }
  }

  // Cheap change detector for the auto-refresh: the generators stamp each file
  // with generated_at; history has no stamp, so its newest entry stands in.
  _dataStamp() {
    const g = (d) => (d && d.generated_at) || '';
    const h = this.history && this.history.entries && this.history.entries[0];
    return [g(this.updates), g(this.cves), g(this.artifacts), (h && h.ts) || ''].join('|');
  }

  parseTabFromUrl() {
    const allowed = new Set(['overview', 'updates', 'improvements', 'security', 'recovery', 'history']);
    const seg = window.location.pathname.replace(/^\/updater\/?/, '').split('/')[0];
    if (seg && allowed.has(seg)) return seg;
    return null;
  }

  bindEvents() {
    if (this._eventBound) return;
    this._eventBound = true;

    // Repaint when an updater/backup task completes (debounced via the
    // coordinator). Self-guards against a torn-down page.
    this.services.tasks && this.services.tasks.refresh && this.services.tasks.refresh.register({
      id: 'updater',
      match: (d) => /^(updater_|artifact_|libreportal\s+(updater|artifact))/.test((d && (d.action || (d.task && d.task.command))) || ''),
      run: () => {
        if (window.updaterPage === this && document.getElementById('updater-page')) {
          return this.refreshAll().then(() => this.render());
        }
      },
      debounceMs: 800,
    });

    // Delegated click handling on the whole layout (sidebar is a sibling of the
    // page card, both inside .updater-layout). The element is replaced on
    // navigation, so the listener is GC'd with it — no cross-page leak.
    const root = document.querySelector('.updater-layout');
    if (!root) return;
    root.addEventListener('click', (e) => {
      const tabBtn = e.target.closest('.sidebar .category[data-updater-tab]');
      if (tabBtn) { this.switchTab(tabBtn.dataset.updaterTab); return; }

      const action = e.target.closest('[data-updater-action]');
      if (!action) return;
      const app = action.dataset.app || null;
      switch (action.dataset.updaterAction) {
        case 'check':      this.checkForUpdates(); break;
        case 'update':     this.applyUpdate(app); break;
        case 'update-all': this.applyAll(); break;
        case 'rollback':   this.rollback(app); break;
        case 'apply-artifact':  this.applyArtifact(action.dataset.id); break;
        case 'revert-artifact': this.revertArtifact(action.dataset.id); break;
        case 'goto':       this.switchTab(action.dataset.tab); break;
      }
    });
  }

  applyActiveTabUi(tab) {
    document.querySelectorAll('.updater-layout .sidebar .category[data-updater-tab]').forEach(b => {
      b.classList.toggle('active', b.dataset.updaterTab === tab);
    });
    document.querySelectorAll('.updater-tabpanel').forEach(p => {
      p.classList.toggle('active', p.id === `updater-panel-${tab}`);
    });
  }

  switchTab(tab) {
    if (!tab || tab === this.currentTab) return;
    this.currentTab = tab;
    this.applyActiveTabUi(tab);
    this.updateHeader();
    this.render();
    const url = `/updater/${tab}`;
    if (!this._pushedAnyTab) { window.history.replaceState({ route: url }, '', url); this._pushedAnyTab = true; }
    else { window.history.pushState({ route: url }, '', url); }
  }

  // ---- data ----------------------------------------------------------------

  async refreshAll() {
    const get = (url) => fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() : null).catch(() => null);
    const [u, c, h, av] = await Promise.all([
      get('/data/updater/generated/updates.json'),
      get('/data/updater/generated/cves.json'),
      get('/data/updater/generated/history.json'),
      get('/data/updater/generated/artifacts_available.json'),
    ]);
    this.updates = u; this.cves = c; this.history = h; this.artifacts = av;
    this.apps = this.mergeApps();
  }

  // Build the per-app view. Prefer the generator's updates.json; otherwise fall
  // back to the installed-apps list (window.apps / DataLoader) so the page is
  // still useful before the first scan ("status unknown — run a check").
  mergeApps() {
    const cveByApp = {};
    if (this.cves && Array.isArray(this.cves.apps)) {
      for (const a of this.cves.apps) cveByApp[a.name] = a.cves || [];
    }
    let base = (this.updates && Array.isArray(this.updates.apps)) ? this.updates.apps : null;
    if (!base) {
      const installed = (window.apps || []).filter(a => a && (a.status === 1 || a.installed || a.is_installed));
      base = installed.map(a => ({
        name: a.name || a.app_name,
        displayName: a.displayName || a.title || a.name || a.app_name,
        current_version: a.version || null,
        available_version: null,
        update_available: false,
        scanned: false,
      }));
    }
    return base.map(a => {
      const cves = cveByApp[a.name] || [];
      const sev = this.worstSeverity(cves);
      return Object.assign({ displayName: a.displayName || a.name, scanned: a.scanned !== false }, a, { cves, worstSeverity: sev });
    });
  }

  worstSeverity(cves) {
    const order = ['critical', 'high', 'medium', 'low'];
    for (const s of order) if (cves.some(c => (c.severity || '').toLowerCase() === s)) return s;
    return null;
  }

  // ---- derived counts ------------------------------------------------------

  counts() {
    const updatesAvailable = this.apps.filter(a => a.update_available).length;
    const cveTotals = (this.cves && this.cves.totals) || this.tallyCves();
    const totalCves = (cveTotals.critical || 0) + (cveTotals.high || 0) + (cveTotals.medium || 0) + (cveTotals.low || 0);
    const drReady = this.apps.filter(a => a.dr_ready !== false).length; // snapshot-before-update is on by default
    const artList = (this.artifacts && Array.isArray(this.artifacts.artifacts)) ? this.artifacts.artifacts : [];
    const improvements = artList.filter(a => a.applicable && !a.applied).length;
    return { apps: this.apps.length, updatesAvailable, cveTotals, totalCves, drReady, improvements, lastChecked: this.updates && this.updates.generated_at };
  }

  tallyCves() {
    const t = { critical: 0, high: 0, medium: 0, low: 0 };
    for (const a of this.apps) for (const c of (a.cves || [])) {
      const s = (c.severity || '').toLowerCase(); if (t[s] != null) t[s]++;
    }
    return t;
  }

  // ---- actions (all via the task system) -----------------------------------

  checkForUpdates() {
    this.dispatch('updater_check', {}, 'Checking apps for updates & vulnerabilities…');
  }
  applyUpdate(app) {
    if (!app) return;
    this.dispatch('updater_apply', { app }, `Updating ${app} (a recovery snapshot is taken first)…`);
  }
  applyAll() {
    const list = this.apps.filter(a => a.update_available).map(a => a.name);
    if (!list.length) { this.toast('Everything is up to date.', 'info'); return; }
    this.dispatch('updater_apply_all', { apps: list.join(',') }, `Updating ${list.length} app(s) — each is snapshotted first…`);
  }
  rollback(app) {
    if (!app) return;
    this.dispatch('updater_rollback', { app }, `Rolling ${app} back to its pre-update snapshot…`);
  }
  applyArtifact(id) {
    if (!id) return;
    this.dispatch('artifact_apply', { id }, `Applying hotfix ${id} (a snapshot is taken first)…`);
  }
  revertArtifact(id) {
    if (!id) return;
    this.dispatch('artifact_revert', { id }, `Reverting hotfix ${id}…`);
  }

  dispatch(action, params, note) {
    const route = this.services.tasks && this.services.tasks.route;
    if (route && typeof route.routeAction === 'function') {
      route.routeAction(action, params || {});
      this.toast(note || 'Working…', 'info');
    } else if (typeof route === 'function') {
      route(action, params || {});
      this.toast(note || 'Working…', 'info');
    } else {
      this.toast('Task system not ready — try again in a moment.', 'error');
    }
  }

  toast(msg, type) {
    const n = this.services.notify;
    if (n && typeof n.show === 'function') n.show(msg, type || 'info');
  }

  // ---- rendering -----------------------------------------------------------

  updateHeader() {
    const titles = {
      overview: ['Overview', 'Update health, security posture, and recovery readiness at a glance.'],
      updates:  ['Updates', 'Available versions per app. Every update is snapshotted first, so it is reversible.'],
      improvements: ['Improvements', 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.'],
      security: ['Security', 'Known vulnerabilities (CVEs) in your installed app images, by severity.'],
      recovery: ['Disaster Recovery', 'Pre-update snapshots and rollback points — undo any update.'],
      history:  ['History', 'A log of update and rollback activity.'],
    };
    const t = titles[this.currentTab] || titles.overview;
    const titleEl = document.getElementById('updater-section-title');
    const subEl = document.getElementById('updater-section-subtitle');
    if (titleEl) titleEl.textContent = t[0];
    if (subEl) subEl.textContent = t[1];
    // Fill the shared page-header icon slot once (update-cycle glyph).
    const iconEl = document.getElementById('updater-page-header-icon');
    if (iconEl && !iconEl.firstChild) {
      iconEl.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"></path><path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path><path d="M3 22v-6h6"></path><path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path></svg>';
    }
  }

  render() {
    const panel = document.getElementById(`updater-panel-${this.currentTab}`);
    if (!panel) return;
    switch (this.currentTab) {
      case 'overview':  panel.innerHTML = this.renderOverview(); break;
      case 'updates':   panel.innerHTML = this.renderUpdates(); break;
      case 'improvements': panel.innerHTML = this.renderImprovements(); break;
      case 'security':  panel.innerHTML = this.renderSecurity(); break;
      case 'recovery':  panel.innerHTML = this.renderRecovery(); break;
      case 'history':   panel.innerHTML = this.renderHistory(); break;
    }
  }

  renderOverview() {
    const c = this.counts();
    const sev = c.cveTotals;
    const checked = c.lastChecked ? this.fmtRel(c.lastChecked) : 'never';
    const card = (hue, big, label, sub, action) => `
      <div class="updater-stat" style="--page: var(--page-${hue}); --page-rgb: var(--page-${hue}-rgb);">
        <div class="updater-stat-big">${big}</div>
        <div class="updater-stat-label">${label}</div>
        ${sub ? `<div class="updater-stat-sub">${sub}</div>` : ''}
        ${action || ''}
      </div>`;
    return `
      <div class="updater-stat-grid">
        ${card('updates', c.updatesAvailable, 'Updates available', c.updatesAvailable ? 'across your apps' : "you're current",
            `<button class="updater-btn updater-btn-primary" data-updater-action="goto" data-tab="updates">Review</button>`)}
        ${card('verify', c.totalCves, 'Known CVEs',
            sev.critical || sev.high ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues',
            `<button class="updater-btn" data-updater-action="goto" data-tab="security">View</button>`)}
        ${card('setup', c.improvements, 'Improvements', c.improvements ? 'signed hotfixes to apply' : 'nothing pending',
            `<button class="updater-btn" data-updater-action="goto" data-tab="improvements">View</button>`)}
        ${card('backups', `${c.drReady}/${c.apps}`, 'Recovery-ready', 'snapshot taken before each update',
            `<button class="updater-btn" data-updater-action="goto" data-tab="recovery">Recovery</button>`)}
        ${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`,
            `<button class="updater-btn" data-updater-action="check">Check now</button>`)}
      </div>
      ${this.updates ? '' : `<div class="updater-hint">No scan data yet — showing your installed apps. The first automatic scan runs within a couple of minutes, or hit <strong>Check now</strong>.</div>`}`;
  }

  renderUpdates() {
    if (!this.apps.length) return this.empty('No apps installed yet — add one from the App Center and its updates will show up here.');
    const rows = this.apps.map(a => {
      const cur = this.escape(a.current_version || a.current_image || '—');
      const avail = a.update_available ? this.escape(a.available_version || a.available_image || 'newer') : null;
      const badge = a.update_available
        ? `<span class="updater-badge updater-badge-update">update</span>`
        : (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
      const sev = a.worstSeverity ? `<span class="updater-badge sev-${a.worstSeverity}">${a.worstSeverity}</span>` : '';
      const btn = a.update_available
        ? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${this.escape(a.name)}">Update</button>`
        : '';
      return `<div class="updater-row">
        <div class="updater-row-main"><span class="updater-row-name">${this.escape(a.displayName)}</span> ${badge} ${sev}</div>
        <div class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</div>
        <div class="updater-row-actions">${btn}</div>
      </div>`;
    }).join('');
    const anyUpdate = this.apps.some(a => a.update_available);
    return `
      <div class="updater-toolbar">
        <button class="updater-btn" data-updater-action="check">↻ Check for updates</button>
        ${anyUpdate ? `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>` : ''}
      </div>
      <div class="updater-list">${rows}</div>`;
  }

  // withToolbar=false lets an embedding surface (the fleet Overview tab) skip
  // the inline Check button because it provides one in its own header.
  renderImprovements(withToolbar = true) {
    // No inline Check button on the empty state: the host auto-scan repopulates
    // this within a couple of minutes (and the embedding header already carries
    // a manual Check), so the message alone is the right, button-free empty UI.
    if (!this.artifacts) return this.empty('Nothing here yet — LibrePortal checks for improvements automatically in the background.');
    const list = Array.isArray(this.artifacts.artifacts) ? this.artifacts.artifacts : [];
    const signed = !!this.artifacts.signed;
    if (!list.length) return this.empty('No improvements available right now — you are all caught up. 🎉');
    // Map the hotfix severities onto the existing CVE severity colour classes.
    const sevClass = { security: 'sev-critical', breakage: 'sev-high', compat: 'sev-medium', tweak: 'sev-low' };
    const rows = list.map(a => {
      const sv = sevClass[a.severity] || 'sev-low';
      const scope = a.app ? this.escape(a.app) : 'system';
      const appliedBadge = a.applied ? '<span class="updater-badge updater-badge-ok">applied</span>' : '';
      const autoBadge = a.auto ? '<span class="updater-badge updater-badge-update">auto</span>' : '';
      const naBadge = a.applicable ? '' : '<span class="updater-badge updater-badge-unknown">not applicable</span>';
      let btn = '';
      if (a.applied) btn = `<button class="updater-btn" data-updater-action="revert-artifact" data-id="${this.escape(a.id)}">Revert</button>`;
      else if (a.applicable && signed) btn = `<button class="updater-btn updater-btn-primary" data-updater-action="apply-artifact" data-id="${this.escape(a.id)}">Apply</button>`;
      return `<div class="updater-row">
        <div class="updater-row-main"><span class="updater-row-name">${this.escape(a.title || a.id)}</span>
          <span class="updater-badge ${sv}">${this.escape(a.severity || 'tweak')}</span>
          <span class="updater-badge updater-badge-unknown">${scope}</span>
          ${appliedBadge} ${autoBadge} ${naBadge}</div>
        <div class="updater-row-ver">${this.escape(a.why || '')}</div>
        <div class="updater-row-actions">${btn}</div>
      </div>`;
    }).join('');
    const banner = signed
      ? `<div class="updater-hint">Small, signed, individually-reversible improvements curated by the LibrePortal team. Security &amp; breakage fixes apply automatically (a snapshot is taken first); the rest are one click. Every apply is logged in History and can be reverted.</div>`
      : `<div class="updater-hint">⚠ The improvements index is <strong>unsigned</strong> (signing not activated on this build) — applying is disabled for safety.</div>`;
    return `${banner}
      ${withToolbar ? `<div class="updater-toolbar"><button class="updater-btn" data-updater-action="check">↻ Check for improvements</button></div>` : ''}
      <div class="updater-list">${rows}</div>`;
  }

  renderSecurity() {
    const withCves = this.apps.filter(a => (a.cves || []).length);
    // No inline Check button: the host auto-scan runs the vulnerability scan on
    // its own within a couple of minutes (and the embedding header carries a
    // manual Check), so the message alone is the right button-free empty UI.
    if (!this.cves) return this.empty('No vulnerability scan yet — one runs automatically within a couple of minutes.');
    if (!withCves.length) return this.empty('No known vulnerabilities in your installed apps. 🎉');
    const blocks = withCves.map(a => {
      const items = (a.cves || []).map(c => `
        <div class="updater-cve sev-${(c.severity || 'low').toLowerCase()}">
          <span class="updater-cve-sev">${this.escape((c.severity || '').toUpperCase())}</span>
          <a class="updater-cve-id" href="${this.escape(c.url || ('https://nvd.nist.gov/vuln/detail/' + (c.id || '')))}" target="_blank" rel="noopener">${this.escape(c.id || 'CVE')}</a>
          <span class="updater-cve-pkg">${this.escape(c.package || '')}</span>
          ${c.fixed_in ? `<span class="updater-cve-fix">fixed in ${this.escape(c.fixed_in)}</span>` : ''}
        </div>`).join('');
      return `<div class="updater-cve-app"><div class="updater-cve-app-name">${this.escape(a.displayName)} <span class="updater-badge sev-${a.worstSeverity}">${(a.cves || []).length}</span></div>${items}</div>`;
    }).join('');
    return `<div class="updater-list">${blocks}</div>`;
  }

  renderRecovery() {
    const rows = this.apps.map(a => {
      const snap = a.last_snapshot ? `${this.escape(a.last_snapshot_version || '')} · ${this.fmtRel(a.last_snapshot_at)}` : 'will be created on next update';
      const can = !!a.last_snapshot;
      return `<div class="updater-row">
        <div class="updater-row-main"><span class="updater-row-name">${this.escape(a.displayName)}</span>
          <span class="updater-badge ${can ? 'updater-badge-ok' : 'updater-badge-unknown'}">${can ? 'recoverable' : 'protected'}</span></div>
        <div class="updater-row-ver">${snap}</div>
        <div class="updater-row-actions">${can ? `<button class="updater-btn" data-updater-action="rollback" data-app="${this.escape(a.name)}">Roll back</button>` : ''}</div>
      </div>`;
    }).join('');
    return `
      <div class="updater-hint">Disaster recovery is automatic: before any app update, LibrePortal snapshots that app (via the Backup engine) so the update can be rolled back. Apps below show their latest rollback point.</div>
      <div class="updater-list">${rows}</div>`;
  }

  renderHistory() {
    const entries = (this.history && this.history.entries) || [];
    if (!entries.length) return this.empty('No update activity yet.');
    const rows = entries.map(e => `
      <div class="updater-row">
        <div class="updater-row-main"><span class="updater-row-name">${this.escape(e.app)}</span>
          <span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span></div>
        <div class="updater-row-ver">${this.escape(e.from || '')}${e.to ? ` <span class="updater-arrow">→</span> ${this.escape(e.to)}` : ''}</div>
        <div class="updater-row-actions">${this.fmtRel(e.ts)}</div>
      </div>`).join('');
    return `<div class="updater-list">${rows}</div>`;
  }

  // Per-app detail body — composes one app's security (CVEs), recovery point,
  // and recent history. Pure HTML string with no DOM assumptions, so it is reused
  // verbatim as the fleet Updates expander body (overview-manager.js) AND the
  // per-app Updater tab. Action buttons keep the data-updater-action/data-app
  // contract, so whichever delegated handler is in scope drives them.
  renderAppDetail(app, opts = {}) {
    const a = app || {};
    // Optional leading "Version" section — the per-app Updates tab shows the
    // current/available version + status badge as a section in the panel. The
    // fleet rows omit it (the row head already shows the version).
    let versionSection = '';
    if (opts.includeVersion) {
      const cur = this.escape(a.current_version || a.current_image || '—');
      const avail = a.update_available ? this.escape(a.available_version || a.available_image || 'newer') : null;
      const badge = a.update_available
        ? `<span class="updater-badge updater-badge-update">update available</span>`
        : (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
      versionSection = `<div class="updater-detail-section"><h4>Version</h4>
        <div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div></div>`;
    }
    const cves = a.cves || [];
    const cveItems = cves.map((c) => `
        <div class="updater-cve sev-${(c.severity || 'low').toLowerCase()}">
          <span class="updater-cve-sev">${this.escape((c.severity || '').toUpperCase())}</span>
          <a class="updater-cve-id" href="${this.escape(c.url || ('https://nvd.nist.gov/vuln/detail/' + (c.id || '')))}" target="_blank" rel="noopener">${this.escape(c.id || 'CVE')}</a>
          <span class="updater-cve-pkg">${this.escape(c.package || '')}</span>
          ${c.fixed_in ? `<span class="updater-cve-fix">fixed in ${this.escape(c.fixed_in)}</span>` : ''}
        </div>`).join('');
    const security = `<div class="updater-detail-section"><h4>Security</h4>${
      cves.length ? cveItems : '<p class="updater-detail-empty">No known CVEs. 🎉</p>'}</div>`;

    // A rollback target exists if a snapshot field is present (future-proofing)
    // OR — the data the generator actually emits today — this app has a prior
    // update/rollback in history (updater_apply always snapshots first, so that
    // pre-update snapshot is the rollback point). Gating only on the never-
    // written last_snapshot* fields would make the Roll back button unreachable.
    const priorUpdate = ((this.history && this.history.entries) || [])
      .some((e) => e.app === a.name && (e.action === 'update' || e.action === 'rollback'));
    const can = !!a.last_snapshot || priorUpdate;
    const snap = a.last_snapshot
      ? `${this.escape(a.last_snapshot_version || '')} · ${this.fmtRel(a.last_snapshot_at)}`
      : (priorUpdate
        ? 'Roll back to the snapshot taken before the last update.'
        : 'A recovery snapshot is taken automatically before the next update.');
    const recovery = `<div class="updater-detail-section"><h4>Recovery</h4>
        <div class="updater-detail-row"><span class="updater-badge ${can ? 'updater-badge-ok' : 'updater-badge-unknown'}">${can ? 'recoverable' : 'protected'}</span>
        <span class="updater-detail-meta">${snap}</span>
        ${can ? `<button class="updater-btn" data-updater-action="rollback" data-app="${this.escape(a.name)}">Roll back</button>` : ''}</div></div>`;

    const entries = ((this.history && this.history.entries) || []).filter((e) => e.app === a.name).slice(0, 8);
    const history = entries.length ? `<div class="updater-detail-section"><h4>History</h4>${entries.map((e) => `
        <div class="updater-detail-row"><span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span>
        <span class="updater-detail-meta">${this.escape(e.from || '')}${e.to ? ` → ${this.escape(e.to)}` : ''}</span>
        <span class="updater-detail-meta">${this.fmtRel(e.ts)}</span></div>`).join('')}</div>` : '';

    return `<div class="updater-detail">${versionSection}${security}${recovery}${history}</div>`;
  }

  empty(msg, withCheck) {
    return `<div class="updater-empty">${this.escape(msg)}${withCheck ? `<div><button class="updater-btn updater-btn-primary" data-updater-action="check">Check now</button></div>` : ''}</div>`;
  }

  // ---- utils ---------------------------------------------------------------

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

  fmtRel(ts) {
    if (!ts) return 'never';
    const t = typeof ts === 'number' ? ts : Date.parse(ts);
    if (!t || isNaN(t)) return this.escape(String(ts));
    const s = Math.max(0, (Date.now() - t) / 1000);
    if (s < 60) return 'just now';
    if (s < 3600) return `${Math.floor(s / 60)}m ago`;
    if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
    return `${Math.floor(s / 86400)}d ago`;
  }
}

window.UpdaterPage = UpdaterPage;
