// components/apps/overview/js/overview-manager.js — the fleet "Overview" area.
//
// One place for a whole-fleet picture: Overview (combined health), Updates
// (per-app expander table + filter chips), Improvements (signed hotfixes), and
// Backups (per-app backup health). It lives inside the apps feature so it shares
// the persistent apps sidebar — selecting "Overview" in the sidebar renders this
// in the main pane, exactly like selecting an app renders the per-app tabs.
//
// Reuse, not duplication: a *headless* UpdaterPage supplies all update/CVE/
// improvement data + row rendering (its renderX() methods are pure HTML-string
// producers), and we route its action buttons straight to its methods. Backup
// health is read directly from the same generated JSON the backup page uses.
// System config edits are never inline here — they bounce to Admin / the per-app
// tabs (operate/view here; change-the-system there).

// Single source of truth for every fleet tab's in-content header. Each tab
// renders the SAME .config-title block the per-app detail tabs use — emoji +
// title + description, sitting inside the pane, directly above the body and
// under the tab strip. renderHeader() is the only thing that turns this into
// markup, and renderTab()/mountMigrate() are the only callers, so the area
// cannot drift from the shared app-detail header format: to change a heading,
// edit it HERE and nowhere else. (Backups is the one exception — it embeds the
// full BackupPage, which supplies its own header — so it has no entry to render
// but is listed for completeness.)
const OV_TAB_META = {
  overview:     { icon: '🛰️', title: 'Overview',     desc: 'What needs your attention across all your apps — updates, security, improvements, and backups.' },
  updates:      { icon: '⬆️', title: 'Updates',      desc: 'Available versions per app — expand a row for CVEs, recovery, and history. Every update is snapshotted first.' },
  improvements: { icon: '✨', title: 'Improvements', desc: 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.' },
  backups:      { icon: '💾', title: 'Backups',      desc: 'Backup health across your apps. Open an app for snapshots and restore.' },
  migrate:      { icon: '🔀', title: 'Migrate',      desc: 'Move apps in from another LibrePortal, and manage the peers you share backup locations with.' },
};

class OverviewManager {
  constructor(services) {
    this.services = services || (window.LP && window.LP.services) || {};
    this.updater = null;        // headless UpdaterPage (data + renderers)
    this.backup = null;         // /data/backup/generated/dashboard.json
    this.tabs = null;           // TabController (show/hide only)
    this.filter = 'all';        // Updates-tab chip: all | updates | security
    this.board = 'all';         // Overview-tab chip: all | action
    this.current = null;
  }

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

  async initialize() {
    const root = document.getElementById('overview-view');
    if (!root) return;
    if (typeof UpdaterPage !== 'undefined' && !this.updater) {
      this.updater = new UpdaterPage(this.services);
    }
    this.bindEvents(root);
    this.tabs = new TabController(root);
    const initial = this.parseTabFromUrl() || 'overview';
    await this.refreshAll();
    this._applyTab(initial); // active + header + render, no history push
    // Keep an open Overview in step with the host-side auto-scan (no task event
    // fires for it). The headless updater owns the timer + change detection;
    // re-armed here on every mount, released by the apps feature's unmount.
    // isActive: the pane element persists hidden in the shared layout, so gate
    // on it actually being shown (offsetParent is null while display:none).
    if (this.updater && this.updater.startAutoRefresh) {
      this.updater.startAutoRefresh(
        () => this._applyTab(this.current || 'overview'),
        () => {
          const pane = window.overviewManager === this && document.getElementById('overview-view');
          return !!(pane && pane.offsetParent);
        }
      );
    }
  }

  bindEvents(root) {
    // One delegated click listener per fresh root. The apps layout fragment
    // persists across /apps,/app,/overview, so the same root is reused — the
    // dataset guard stops us stacking a second listener on revisit; a fresh
    // fragment (after leaving the apps feature) gets a new root + new listener
    // while the old one is GC'd with its element. The closure binds the
    // singleton `this`, so it never goes stale.
    if (root.dataset.ovBound !== '1') {
      root.dataset.ovBound = '1';
      root.addEventListener('click', (e) => this._handleClick(e));
      // Keyboard activation for the role="button" elements (expander row-heads,
      // backup tiles): Enter/Space reuse the same click dispatch.
      root.addEventListener('keydown', (e) => {
        if (e.key !== 'Enter' && e.key !== ' ') return;
        const t = e.target.closest('[data-overview-action], [role="button"]');
        if (!t || !root.contains(t)) return;
        e.preventDefault();
        this._handleClick(e);
      });
    }
    // Repaint when a relevant task finishes — debounced via the shared
    // coordinator, idempotent by id (re-registering replaces), self-guarded
    // against a torn-down view. Called on every initialize() so it re-arms after
    // unmount() unregistered it on the way out of the apps feature.
    const tr = this.services.tasks && this.services.tasks.refresh;
    if (tr && tr.register) {
      tr.register({
        id: 'overview',
        match: (d) => /^(updater_|artifact_|backup|restore|libreportal\s+(updater|artifact|backup|restore))/
          .test((d && (d.action || (d.task && d.task.command))) || ''),
        run: () => {
          if (window.overviewManager === this && document.getElementById('overview-view')) {
            return this.refreshAll().then(() => this._applyTab(this.current || 'overview'));
          }
        },
        debounceMs: 800,
      });
    }
  }

  async refreshAll() {
    const jobs = [];
    if (this.updater) jobs.push(this.updater.refreshAll());
    jobs.push(
      fetch('/data/backup/generated/dashboard.json', { cache: 'no-store' })
        .then((r) => (r.ok ? r.json() : null))
        .catch(() => null)
        .then((d) => { this.backup = d; })
    );
    await Promise.all(jobs);
  }

  parseTabFromUrl() {
    const allowed = new Set(['overview', 'updates', 'improvements', 'backups', 'migrate']);
    const seg = window.location.pathname.replace(/^\/apps\/overview\/?/, '').split('/')[0];
    return (seg && allowed.has(seg)) ? seg : null;
  }

  // ---- tab switching -------------------------------------------------------

  // Called by the tab buttons' inline onclick AND by in-page "goto" buttons.
  switchTab(id) {
    if (!id || id === this.current) return;
    this._applyTab(id);
    const url = id === 'overview' ? '/apps/overview' : `/apps/overview/${id}`;
    this._pushUrl(url, false);
  }

  // Push/replace the URL AND keep the SPA kernel's currentRoute in sync —
  // these tab switches bypass spaClean.navigate(), and a stale currentRoute
  // would make its de-dupe guard turn the sidebar "Overview" entry into a
  // no-op after an in-page tab switch.
  _pushUrl(url, replace) {
    if (replace) window.history.replaceState({ route: url }, '', url);
    else window.history.pushState({ route: url }, '', url);
    try { if (window.spaClean) window.spaClean.currentRoute = url; } catch (_) {}
  }

  _applyTab(id) {
    if (this.tabs) this.tabs.switch(id);
    this.renderTab(id);
    this.current = id;
  }

  // The shared in-content header. Sourced only from OV_TAB_META so every fleet
  // tab matches the per-app detail tabs' title block (emoji + title + blurb
  // left, action buttons right, divider underneath — the .backup-title shape).
  // renderTab() prepends this for the string tabs; mountMigrate() injects it
  // once for the static Migrate pane.
  renderHeader(id, actions = '') {
    const m = OV_TAB_META[id];
    if (!m) return '';
    return `<div class="config-title ov-tab-header">
      <div class="ov-tab-header-main"><h3>${m.icon} ${this.escape(m.title)}</h3><p>${this.escape(m.desc)}</p></div>
      ${actions ? `<div class="ov-tab-header-actions">${actions}</div>` : ''}
    </div>`;
  }

  renderTab(id) {
    const pane = document.querySelector(`#overview-view .tab-pane[data-tab="${id}"]`);
    if (!pane) return;
    // Every string-rendered tab follows the per-app detail tab idiom: the
    // shared header (title + actions + divider) on top, then the body inside
    // the recessed .ov-tab-body container — so the renderers below only ever
    // produce the body (never a heading or its action buttons).
    const checkBtn = (label) => `<button class="updater-btn" data-updater-action="check">↻ ${label}</button>`;
    const body = (html) => `<div class="ov-tab-body">${html}</div>`;
    switch (id) {
      case 'overview':
        pane.innerHTML = this.renderHeader(id, checkBtn('Check now')) + body(this.renderOverview());
        break;
      case 'updates': {
        // Preserve which rows are expanded across a rebuild — a background
        // task-refresh repaints the whole table, so restore ALL open rows, not
        // just the single ?app= deep-link.
        const open = Array.from(document.querySelectorAll('#overview-view .ov-row-details:not([hidden])'))
          .map((d) => d.id.replace(/^ov-detail-/, ''));
        const anyUpdate = !!(this.updater && this.updater.apps.some((a) => a.update_available));
        const actions = checkBtn('Check')
          + (anyUpdate ? ` <button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>` : '');
        pane.innerHTML = this.renderHeader(id, actions) + body(this.renderUpdates());
        open.forEach((app) => this._openDetail(app));
        this._honorAppDeepLink();
        break;
      }
      case 'improvements':
        pane.innerHTML = this.renderHeader(id, checkBtn('Check')) + body(this.renderImprovements());
        break;
      case 'backups': this.mountBackupCenter(pane); break;
      case 'migrate': this.mountMigrate(); break;
    }
  }

  // ---- Migrate tab: Restore (standalone MigratePage) + Peers (PeersPage) ----

  mountMigrate() {
    // The Migrate pane is static layout (its sub-tabs live in the HTML, not an
    // innerHTML rebuild), so inject the shared header once, above the sub-tabs.
    const pane = document.getElementById('ov-pane-migrate');
    if (pane && !pane.querySelector(':scope > .ov-tab-header')) {
      pane.insertAdjacentHTML('afterbegin', this.renderHeader('migrate'));
    }
    const seg = window.location.pathname.replace(/^\/apps\/overview\/migrate\/?/, '').split('/')[0];
    const sub = (seg === 'peers' || seg === 'restore') ? seg : (this._migrateSub || 'restore');
    this.switchMigrateSub(sub, { fromUrl: true });
  }

  switchMigrateSub(sub, opts = {}) {
    this._migrateSub = sub;
    const pane = document.getElementById('ov-pane-migrate');
    if (!pane) return;
    pane.querySelectorAll('.ov-subtabs .tab-button[data-ov-subtab]').forEach((b) => b.classList.toggle('active', b.dataset.ovSubtab === sub));
    pane.querySelectorAll('.ov-subtabs-content .tab-panel[data-ov-subtab]').forEach((p) => p.classList.toggle('active', p.dataset.ovSubtab === sub));
    if (sub === 'restore') this._mountRestore();
    else if (sub === 'peers') this._mountPeers();
    if (!opts.fromUrl) this._pushUrl(`/apps/overview/migrate/${sub}`, true);
  }

  async _mountRestore() {
    const panel = document.getElementById('ov-migrate-restore');
    if (!panel) return;
    if (panel.dataset.mounted && window.migratePage) {
      try { window.migratePage.refreshAll().then(() => window.migratePage.render()).catch(() => {}); } catch (_) {}
      return;
    }
    panel.innerHTML = lpLoadingBox();
    try {
      if (typeof MigratePage === 'undefined' && window.spaClean && window.spaClean.loadScript) {
        await window.spaClean.loadScript('/components/apps/overview/migrate/js/migrate-page.js');
      }
      const html = await fetch('/components/apps/overview/migrate/html/migrate-content.html', { cache: 'no-store' }).then((r) => r.text());
      panel.innerHTML = html;
      if (typeof MigratePage === 'undefined') { panel.innerHTML = '<div class="updater-empty">Restore unavailable.</div>'; return; }
      try { if (window.migratePage) window.migratePage.dispose(); } catch (_) {}
      window.migratePage = new MigratePage('ov-migrate-restore');
      await window.migratePage.init();
      panel.dataset.mounted = '1';
    } catch (_) {
      panel.innerHTML = '<div class="updater-empty">Failed to load restore.</div>';
    }
  }

  async _mountPeers() {
    const panel = document.getElementById('ov-migrate-peers');
    if (!panel) return;
    if (panel.dataset.mounted && window.peersPage) {
      try { window.peersPage.refreshAll().then(() => window.peersPage.render()).catch(() => {}); } catch (_) {}
      return;
    }
    panel.innerHTML = lpLoadingBox();
    try {
      if (typeof PeersPage === 'undefined' && window.spaClean && window.spaClean.loadScript) {
        await window.spaClean.loadScript('/components/admin/peers/js/peers-page.js');
      }
      const html = await fetch('/components/admin/peers/html/peers-content.html', { cache: 'no-store' }).then((r) => r.text());
      panel.innerHTML = html;
      if (typeof PeersPage === 'undefined') { panel.innerHTML = '<div class="updater-empty">Peers unavailable.</div>'; return; }
      try { if (window.peersPage && window.peersPage.dispose) window.peersPage.dispose(); } catch (_) {}
      window.peersPage = new PeersPage('ov-migrate-peers');
      await window.peersPage.init();
      panel.dataset.mounted = '1';
    } catch (_) {
      panel.innerHTML = '<div class="updater-empty">Failed to load peers.</div>';
    }
  }

  // Open the row named by ?app=<name> on load / repaint — makes an expanded row
  // a shareable URL, mirroring the Tasks page's ?task=<id> deep-link.
  _honorAppDeepLink() {
    const app = new URLSearchParams(window.location.search).get('app');
    if (!app) return;
    const details = document.getElementById(`ov-detail-${app}`);
    if (details && details.hidden) this._openDetail(app);
  }

  // ---- clicks --------------------------------------------------------------

  _handleClick(e) {
    // Updater actions take precedence over the row-toggle they sit inside, so a
    // click on "Update" never also expands the row.
    const ua = e.target.closest('[data-updater-action]');
    if (ua && this.updater) {
      const app = ua.dataset.app || null;
      switch (ua.dataset.updaterAction) {
        case 'check': this.updater.checkForUpdates(); break;
        case 'update': this.updater.applyUpdate(app); break;
        case 'update-all': this.updater.applyAll(); break;
        case 'rollback': this.updater.rollback(app); break;
        case 'apply-artifact': this.updater.applyArtifact(ua.dataset.id); break;
        case 'revert-artifact': this.updater.revertArtifact(ua.dataset.id); break;
      }
      return;
    }
    const oa = e.target.closest('[data-overview-action]');
    if (oa) {
      switch (oa.dataset.overviewAction) {
        case 'goto':
          // Board rows can land on Updates pre-filtered (e.g. Security → the
          // affected apps only), so honor an optional data-filter on the way.
          if (oa.dataset.filter) this.filter = oa.dataset.filter;
          this.switchTab(oa.dataset.tab);
          break;
        case 'filter': this.filter = oa.dataset.filter || 'all'; this.renderTab('updates'); break;
        case 'board': this.board = oa.dataset.board || 'all'; this.renderTab('overview'); break;
        case 'toggle': this.toggleAppDetails(oa.dataset.app); break;
      }
    }
  }

  // ---- Overview tab (action board) ------------------------------------------

  // An action board, not a stat wall: a health hero on top (one big circle —
  // green all-clear / amber something-to-do / neutral no-data-yet, the admin
  // dashboard's dot language at fleet scale), then one row per area. Rows that
  // want a decision carry their action buttons; healthy areas collapse to a
  // quiet one-liner, and the "Needs action" chip hides them entirely. The raw
  // numbers all live inside the rows and the hero's stat line.
  renderOverview() {
    const rows = this.areaStatuses();
    const warn = rows.filter((r) => r.kind === 'warn');
    const scanned = !!(this.updater && this.updater.updates);
    const u = this.updater ? this.updater.counts() : { apps: 0, lastChecked: null };
    const b = this.backupSummary();
    const kind = warn.length ? 'warn' : (scanned ? 'ok' : 'none');
    const headline = warn.length
      ? `${warn.length} thing${warn.length === 1 ? '' : 's'} need${warn.length === 1 ? 's' : ''} your attention`
      : (scanned ? 'All clear — nothing needs your attention' : 'Waiting for the first scan');
    let sub;
    if (scanned) {
      const checked = u.lastChecked ? this.updater.fmtRel(u.lastChecked) : 'never';
      const bits = [`${u.apps} app${u.apps === 1 ? '' : 's'} tracked`];
      if (this.backup) bits.push(`${b.protectedLabel} backed up`);
      bits.push(`last scan ${checked}`);
      sub = bits.join(' · ');
    } else {
      sub = 'The first automatic scan runs within a couple of minutes — or hit Check now.';
    }
    const chip = (id, label, n) =>
      `<button class="ov-chip${this.board === id ? ' active' : ''}" data-overview-action="board" data-board="${id}">${label}${n ? ` <span class="ov-chip-n">${n}</span>` : ''}</button>`;
    const shown = this.board === 'action' ? warn : rows;
    const list = shown.length
      ? `<div class="ov-board">${shown.map((r) => this.boardRow(r)).join('')}</div>`
      : `<div class="ov-allclear">Nothing needs action right now.</div>`;
    return `
      <div class="ov-health ${kind}">
        <span class="ov-health-dot ${kind}" aria-hidden="true"></span>
        <div class="ov-health-main">
          <div class="ov-health-title">${headline}</div>
          <div class="ov-health-sub">${sub}</div>
        </div>
        <div class="ov-chips" role="group" aria-label="Board filter">${chip('all', 'Everything')}${chip('action', 'Needs action', warn.length)}</div>
      </div>
      ${list}`;
  }

  // One status per area. kind 'warn' = actionable (gets buttons), 'ok' = quiet
  // one-liner, 'none' = no data yet. Amber is the ceiling — per the
  // not-alarmist convention nothing on this board is *broken*, it just wants a
  // decision; severity detail (e.g. critical CVEs) lives in the row's badge.
  areaStatuses() {
    const esc = (s) => this.escape(s);
    const up = this.updater;
    const u = up ? up.counts() : { updatesAvailable: 0, totalCves: 0, cveTotals: {}, improvements: 0, apps: 0 };
    const scanned = !!(up && up.updates);
    const goto = (tab, label, filter, primary) =>
      `<button class="updater-btn${primary ? ' updater-btn-primary' : ''}" data-overview-action="goto" data-tab="${tab}"${filter ? ` data-filter="${filter}"` : ''}>${label}</button>`;
    const rows = [];

    // Updates
    const pending = up ? up.apps.filter((a) => a.update_available) : [];
    if (!scanned) {
      rows.push({ hue: 'updates', icon: '⬆️', kind: 'none', text: 'Updates — not scanned yet' });
    } else if (pending.length) {
      const names = pending.map((a) => a.displayName || a.name);
      const list = names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : '');
      rows.push({
        hue: 'updates', icon: '⬆️', kind: 'warn',
        text: `${pending.length} update${pending.length === 1 ? '' : 's'} available`,
        sub: `${esc(list)} — a recovery snapshot is taken before each update`,
        actions: goto('updates', 'Review', 'updates')
          + `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`,
      });
    } else {
      rows.push({
        hue: 'updates', icon: '⬆️', kind: 'ok',
        text: u.apps ? `Updates — all ${u.apps} app${u.apps === 1 ? '' : 's'} current` : 'Updates — no installed apps to track yet',
      });
    }

    // Security (needs scan data — without it the updates row already says so)
    if (scanned) {
      const sev = u.cveTotals || {};
      if (u.totalCves) {
        const hit = up.apps.filter((a) => (a.cves || []).length).length;
        const order = ['critical', 'high', 'medium', 'low'];
        const worst = order.find((s) => sev[s]);
        rows.push({
          hue: 'verify', icon: '🛡️', kind: 'warn',
          text: `${u.totalCves} known CVE${u.totalCves === 1 ? '' : 's'} across ${hit} app${hit === 1 ? '' : 's'}`,
          badge: worst ? ` <span class="updater-badge sev-${worst}">${worst}</span>` : '',
          sub: order.filter((s) => sev[s]).map((s) => `${sev[s]} ${s}`).join(' · '),
          actions: goto('updates', 'Review', 'security'),
        });
      } else {
        rows.push({ hue: 'verify', icon: '🛡️', kind: 'ok', text: 'Security — no known CVEs' });
      }
    }

    // Improvements (signed hotfixes — independent of the version scan)
    if (u.improvements) {
      rows.push({
        hue: 'updater', icon: '✨', kind: 'warn',
        text: `${u.improvements} signed improvement${u.improvements === 1 ? '' : 's'} ready to apply`,
        sub: 'individually reversible — a snapshot is taken first',
        actions: goto('improvements', 'View'),
      });
    } else {
      rows.push({ hue: 'updater', icon: '✨', kind: 'ok', text: 'Improvements — nothing pending' });
    }

    // Backups
    const b = this.backupSummary();
    if (!this.backup) {
      rows.push({ hue: 'backups', icon: '💾', kind: 'none', text: 'Backups — no backup data yet' });
    } else if (b.apps && !b.locations) {
      rows.push({
        hue: 'backups', icon: '💾', kind: 'warn',
        text: 'No backup location configured',
        sub: 'apps can’t be protected until there’s somewhere to back them up to',
        actions: goto('backups', 'Open Backups'),
      });
    } else if (b.never + b.old) {
      const n = b.never + b.old;
      rows.push({
        hue: 'backups', icon: '💾', kind: 'warn',
        text: `${n} app${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} a fresh backup`,
        sub: [b.never && `${b.never} never backed up`, b.old && `${b.old} older than a week`].filter(Boolean).join(' · '),
        actions: goto('backups', 'Open Backups'),
      });
    } else {
      rows.push({
        hue: 'backups', icon: '💾', kind: 'ok',
        text: b.apps ? `Backups — all ${b.apps} app${b.apps === 1 ? '' : 's'} protected · last ${b.latest}` : 'Backups — no apps to protect yet',
      });
    }
    return rows;
  }

  boardRow(r) {
    return `<div class="ov-board-row ${r.kind}" style="--page: var(--page-${r.hue}); --page-rgb: var(--page-${r.hue}-rgb);">
      <span class="ov-dot ${r.kind}" aria-hidden="true"></span>
      <span class="ov-board-icon" aria-hidden="true">${r.icon}</span>
      <div class="ov-board-main">
        <div class="ov-board-title">${r.text}${r.badge || ''}</div>
        ${r.sub ? `<div class="ov-board-sub">${r.sub}</div>` : ''}
      </div>
      ${r.actions ? `<div class="ov-board-actions">${r.actions}</div>` : ''}
    </div>`;
  }

  backupSummary() {
    const d = this.backup;
    if (!d) return { apps: 0, protectedLabel: '—', never: 0, old: 0, locations: 0, latest: 'never' };
    const apps = Array.isArray(d.apps) ? d.apps : [];
    const now = Date.now();
    let never = 0, old = 0, newest = 0;
    for (const a of apps) {
      if (!a.latest_snapshot) { never++; continue; }
      const t = Date.parse(a.latest_time);
      if (isNaN(t) || (now - t) > 7 * 86400 * 1000) old++;
      if (!isNaN(t) && t > newest) newest = t;
    }
    const sysT = d.system ? Date.parse(d.system.latest_time) : NaN;
    if (!isNaN(sysT) && sysT > newest) newest = sysT;
    return {
      apps: apps.length,
      protectedLabel: `${apps.length - never}/${apps.length}`,
      never, old,
      locations: Array.isArray(d.locations) ? d.locations.length : 0,
      latest: (newest && this.updater) ? this.updater.fmtRel(new Date(newest).toISOString()) : 'never',
    };
  }

  // ---- Updates tab (expander table + filter chips) -------------------------

  renderUpdates() {
    const up = this.updater;
    if (!up || !up.apps.length) return `<div class="updater-empty">No installed apps to track.</div>`;
    const shown = this.filterApps(up.apps);
    const nUpd = up.apps.filter((a) => a.update_available).length;
    const nSec = up.apps.filter((a) => (a.cves || []).length).length;
    const chip = (id, label, n) =>
      `<button class="ov-chip${this.filter === id ? ' active' : ''}" data-overview-action="filter" data-filter="${id}">${label}${n != null ? ` <span class="ov-chip-n">${n}</span>` : ''}</button>`;
    const rows = shown.map((a) => this.updateRow(a)).join('') || `<div class="updater-empty">Nothing matches this filter.</div>`;
    // Check / Update all live in the tab header's action slot (renderTab), so
    // the toolbar is just the filter chips.
    return `
      <div class="updater-toolbar ov-toolbar">
        <div class="ov-chips">${chip('all', 'All', up.apps.length)}${chip('updates', 'Updates', nUpd)}${chip('security', 'Security', nSec)}</div>
      </div>
      <div class="updater-list ov-updates-list">${rows}</div>`;
  }

  filterApps(apps) {
    if (this.filter === 'updates') return apps.filter((a) => a.update_available);
    if (this.filter === 'security') return apps.filter((a) => (a.cves || []).length);
    return apps;
  }

  updateRow(a) {
    const esc = (s) => this.escape(s);
    const cur = esc(a.current_version || a.current_image || '—');
    const avail = a.update_available ? esc(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 updBtn = a.update_available
      ? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${esc(a.name)}">Update</button>`
      : '';
    return `<div class="updater-row ov-row" data-app="${esc(a.name)}">
      <div class="updater-row-head ov-row-head" data-overview-action="toggle" data-app="${esc(a.name)}" role="button" tabindex="0" aria-expanded="false" aria-controls="ov-detail-${esc(a.name)}">
        <span class="ov-chevron" aria-hidden="true">▸</span>
        <span class="updater-row-name">${esc(a.displayName)}</span> ${badge} ${sev}
        <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span>
        <span class="ov-row-actions">${updBtn}</span>
      </div>
      <div class="updater-row-details ov-row-details" id="ov-detail-${esc(a.name)}" role="region" aria-label="${esc(a.displayName)} details" hidden></div>
    </div>`;
  }

  toggleAppDetails(app) {
    if (!app) return;
    const details = document.getElementById(`ov-detail-${app}`);
    if (!details) return;
    const base = `/overview/${this.current || 'updates'}`;
    if (!details.hidden) {
      this._closeDetail(app);
      this._pushUrl(base, true);
    } else {
      this._openDetail(app);
      this._pushUrl(`${base}?app=${encodeURIComponent(app)}`, true);
    }
  }

  _rowHead(app) {
    const root = document.getElementById('overview-view');
    return root && root.querySelector(`.ov-row[data-app="${(window.CSS && CSS.escape) ? CSS.escape(app) : app}"] .ov-row-head`);
  }

  // Fill (lazily) + show a row's detail body. URL/history is handled by the
  // caller, so this is safe to call when restoring multiple rows after a
  // rebuild without each one clobbering the ?app= deep-link.
  _openDetail(app) {
    const details = document.getElementById(`ov-detail-${app}`);
    if (!details) return;
    if (!details.dataset.filled && this.updater) {
      const appObj = (this.updater.apps || []).find((x) => x.name === app);
      details.innerHTML = appObj ? this.updater.renderAppDetail(appObj) : '';
      details.dataset.filled = '1';
    }
    details.hidden = false;
    details.classList.add('ov-open');
    const head = this._rowHead(app);
    if (head) { head.setAttribute('aria-expanded', 'true'); head.classList.add('ov-open'); }
  }

  _closeDetail(app) {
    const details = document.getElementById(`ov-detail-${app}`);
    if (!details) return;
    details.hidden = true;
    details.classList.remove('ov-open');
    const head = this._rowHead(app);
    if (head) { head.setAttribute('aria-expanded', 'false'); head.classList.remove('ov-open'); }
  }

  // ---- Improvements tab (reuse the updater's hotfix renderer) ---------------

  renderImprovements() {
    // withToolbar=false — the fleet tab's "Check" button lives in the shared
    // header action slot, so the embedded renderer skips its own toolbar.
    return this.updater ? this.updater.renderImprovements(false) : `<div class="updater-empty">Improvements unavailable.</div>`;
  }

  // ---- Backups tab (fleet health glance; actions deep-link per app) ---------

  // The Backups tab hosts the real backup center — the BackupPage controller
  // embedded in the pane, with its own dashboard/snapshots/locations/migrate/
  // configuration sections (its sidebar restyled as a nested tab strip). On a
  // revisit we refresh rather than re-mount, to keep its sub-tab + expand state.
  mountBackupCenter(pane) {
    // Honor an optional sub-tab deep-link (/apps/overview/backups/<sub>), e.g.
    // the Restore empty-state's "Open Locations" button.
    const seg = window.location.pathname.replace(/^\/apps\/overview\/backups\/?/, '').split('/')[0];
    const sub = ['dashboard', 'backups', 'locations', 'configuration'].includes(seg) ? seg : null;
    // Detect a prior mount by the embedded fragment in THIS pane — not by
    // #backup-section, which the per-app Backups tab also defines.
    if (pane.querySelector('.backup-layout') && window.overviewBackupPage) {
      try {
        window.overviewBackupPage.refreshAll().then(() => {
          window.overviewBackupPage.render();
          if (sub) window.overviewBackupPage.switchTab(sub);
        }).catch(() => {});
      } catch (_) {}
      return;
    }
    pane.innerHTML = lpLoadingBox('Loading backup center…');
    this._loadBackupCenter(pane, sub);
  }

  async _loadBackupCenter(pane, sub) {
    if (this._backupLoading) return;
    this._backupLoading = true;
    try {
      await this._ensureBackupAssets();
      let html = await fetch('/components/backup/core/html/backup-content.html', { cache: 'no-store' }).then((r) => r.text());
      // Strip ids that would collide with the apps layout (it also has #sidebar
      // and #mobile-overlay). BackupPage selects its own nodes by class, so this
      // is safe; it just keeps the document free of duplicate ids.
      html = html.replace('id="sidebar"', '').replace('<div class="mobile-overlay" id="mobile-overlay"></div>', '');
      // Lead with the shared in-content header (BackupPage supplies only its own
      // per-section headers inside the layout), so Backups matches every other
      // fleet tab. It persists across refreshes since revisits don't reset innerHTML.
      pane.innerHTML = this.renderHeader('backups') + html;
      if (typeof BackupPage === 'undefined') {
        pane.innerHTML = '<div class="updater-empty">Backup center unavailable.</div>';
        return;
      }
      try { if (window.overviewBackupPage) window.overviewBackupPage.dispose(); } catch (_) {}
      window.overviewBackupPage = new BackupPage({ embedded: true });
      await window.overviewBackupPage.init();
      if (sub) { try { window.overviewBackupPage.switchTab(sub); } catch (_) {} }
    } catch (_) {
      pane.innerHTML = '<div class="updater-empty">Failed to load the backup center.</div>';
    } finally {
      this._backupLoading = false;
    }
  }

  // Lazy-load the backup controller bundle once (idempotent: spaClean.loadScript
  // dedupes by URL, and several of these are already in the apps bundle).
  _ensureBackupAssets() {
    if (this._backupAssets) return this._backupAssets;
    const scripts = [
      '/components/backup/core/js/backup-schema.js',
      '/components/backup/core/js/backup-page.js',
      '/components/backup/core/js/backup-fetch-client.js',
      '/components/backup/core/js/backup-cron-schedule.js',
      '/components/backup/dashboard/js/backup-dashboard.js',
      '/components/backup/snapshots/js/backup-snapshots.js',
      '/components/backup/snapshots/js/backup-snapshot-actions.js',
      '/components/backup/locations/js/backup-locations.js',
      '/components/backup/locations/js/backup-location-fields.js',
      '/components/backup/locations/js/backup-location-modal.js',
      '/components/backup/locations/js/backup-ssh-key.js',
      '/components/backup/configuration/js/backup-configuration.js',
      '/components/backup/configuration/js/backup-retention-presets.js',
      '/components/backup/configuration/js/backup-engine-details.js',
      '/core/backup-card/js/backup-app-card.js',
    ];
    const load = (url) => (window.spaClean && window.spaClean.loadScript) ? window.spaClean.loadScript(url) : Promise.resolve();
    const chain = scripts.reduce((p, url) => p.then(() => load(url)), Promise.resolve());
    // Don't memoize a rejection — a transient script-load failure would otherwise
    // brick the backup center for the whole session. Clear the memo on failure so
    // the next open retries.
    this._backupAssets = chain.catch((e) => { this._backupAssets = null; throw e; });
    return this._backupAssets;
  }

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

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

window.OverviewManager = OverviewManager;
