// Backup page controller — restic-engine UI.
// Reads JSON snapshots written by scripts/webui/data/generators/backup/* and
// dispatches actions back into the task system (which calls bash CLI).

// Retention presets — pick the persona that matches you. Each maps to the
// five underlying restic --keep-* values. "Custom" reveals the raw fields.
const BACKUP_RETENTION_PRESETS = {
    'inherit-global': { last: '', daily: '',   weekly: '', monthly: '',   yearly: ''  },
    'self-hosting':   { last: '', daily: '30', weekly: '', monthly: '',   yearly: ''  },
    'personal':       { last: '', daily: '30', weekly: '', monthly: '6',  yearly: ''  },
    'enterprise':     { last: '', daily: '30', weekly: '', monthly: '12', yearly: '5' }
};

const BACKUP_RETENTION_PRESET_META = {
    'inherit-global': { label: 'Inherit global retention',           hint: 'Use whatever the Configuration tab specifies. Pick something else here only when this location needs a different policy.' },
    'self-hosting':   { label: 'Self-hosting',                       hint: '30 days of daily backups. Plenty for a homelab — covers accidental deletes and app screw-ups.' },
    'personal':       { label: 'Personal',                           hint: '30 days of daily backups plus 6 monthly snapshots. Good for personal data where "what did this look like last summer" matters.' },
    'enterprise':     { label: 'Enterprise',                         hint: '30 daily + 12 monthly + 5 yearly. Compliance-style retention with multi-year history.' },
    'custom':         { label: 'Custom…',                            hint: 'Define each retention tier yourself.' }
};

// Per-location field metadata. Configs.json doesn't carry titles for
// CFG_BACKUP_LOC_N_* (locations are dynamic), so we provide them inline.
// ConfigShared.generateField uses TITLE + key-based widget heuristics; the
// regexes in config-options.js / config-shared.js already cover _TYPE,
// _KEEP_*, _SECRET_KEY, _ACCOUNT_KEY for the right widgets.
const BACKUP_LOC_FIELD_DEFS = {
    NAME:             { title: 'Friendly name',                       description: 'Shown in lists and on the dashboard.' },
    ENABLED:          { title: 'Enabled',                             description: 'Push backups to this location.' },
    ENGINE:           { title: 'Engine',                              description: 'Backup engine used at this location.' },
    TYPE:             { title: 'Type',                                description: 'Backend the engine uses to talk to this location.' },
    PATH_MODE:        { title: 'Path Mode',                           description: 'Automatic uses the Default Backup Location from the Backup Engine config (one subfolder per location). Pick Custom to use a specific path (e.g. an attached drive or a NAS mount).' },
    PATH:             { title: 'Custom path',                         description: 'Filesystem path on this server. Used only when Path is set to Custom.' },
    URI:              { title: 'Repository URI (override)',           description: 'Custom restic URI — leave blank to build from the fields below.' },
    SSH_USER:         { title: 'SSH user',                            description: '' },
    SSH_HOST:         { title: 'SSH host',                            description: '' },
    SSH_PORT:         { title: 'SSH port',                            description: '' },
    SSH_PATH:         { title: 'SSH remote path',                     description: 'Path on the remote host where the repo lives.' },
    SSH_AUTH:         { title: 'SSH authentication',                  description: 'Key auth uses ~/.ssh/id_rsa on this host. Password mode pipes via sshpass — restic + borg only; kopia requires keys.' },
    SSH_PASS:         { title: 'SSH password',                        description: 'Used only when SSH authentication is set to Password.' },
    S3_ACCESS_KEY:    { title: 'S3 access key',                       description: '' },
    S3_SECRET_KEY:    { title: 'S3 secret',                           description: '' },
    B2_ACCOUNT_ID:    { title: 'B2 account ID',                       description: '' },
    B2_ACCOUNT_KEY:   { title: 'B2 account key',                      description: '' },
    APPEND_ONLY:      { title: 'Append-only',                         description: 'Ransomware-safe — refuse forget/prune for this location even if LibrePortal itself is compromised. Trades off automatic retention cleanup.' },
    CUSTOM_RETENTION: { title: 'Use custom retention',                description: 'Otherwise this location inherits the global retention.' },
    KEEP_LAST:        { title: 'Keep last',                           description: 'Snapshots to always retain.' },
    KEEP_DAILY:       { title: 'Keep daily',                          description: 'One snapshot per day for this many days.' },
    KEEP_WEEKLY:      { title: 'Keep weekly',                         description: 'One snapshot per week for this many weeks.' },
    KEEP_MONTHLY:     { title: 'Keep monthly',                        description: 'One snapshot per month for this many months.' },
    KEEP_YEARLY:      { title: 'Keep yearly',                         description: 'One snapshot per year for this many years.' }
};

// Fallback for the per-type field schema. The live source is the generator-
// emitted data/backup/generated/schema.json (loaded into this.locSchema and
// read via locFieldsForType); this map is only used if that fetch fails.
// Type leads each list (it shapes the rest of the form); ENGINE stays in the
// list but locFieldGroups folds it into the Advanced tab.
const BACKUP_LOC_FIELDS_BY_TYPE = {
    local:  ['TYPE', 'NAME', 'ENGINE', 'PATH_MODE', 'PATH', 'APPEND_ONLY'],
    sftp:   ['TYPE', 'NAME', 'ENGINE', 'SSH_USER', 'SSH_HOST', 'SSH_PORT', 'SSH_PATH', 'SSH_AUTH', 'SSH_PASS', 'URI', 'APPEND_ONLY'],
    rest:   ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'],
    s3:     ['TYPE', 'NAME', 'ENGINE', 'URI', 'S3_ACCESS_KEY', 'S3_SECRET_KEY', 'APPEND_ONLY'],
    b2:     ['TYPE', 'NAME', 'ENGINE', 'URI', 'B2_ACCOUNT_ID', 'B2_ACCOUNT_KEY', 'APPEND_ONLY'],
    gs:     ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'],
    azure:  ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY'],
    rclone: ['TYPE', 'NAME', 'ENGINE', 'URI', 'APPEND_ONLY']
};

// Suffixes that live in the editor's "Advanced" tab. configs.json can flag
// more via a **ADVANCED** comment marker; this set keeps the known overrides
// advanced even on legacy location.configs that predate the marker. Engine is
// here too — the system picks a sensible default, so most users never touch it.
const LOC_ADVANCED_SUFFIXES = new Set(['ENGINE', 'URI', 'SSH_PORT', 'APPEND_ONLY']);

function backupRetentionDetectPreset(values, includeInherit = false) {
    const norm = (v) => (v == null ? '' : String(v).trim());
    for (const [key, p] of Object.entries(BACKUP_RETENTION_PRESETS)) {
        if (key === 'inherit-global' && !includeInherit) continue;
        if (norm(values.last)    === norm(p.last)    &&
            norm(values.daily)   === norm(p.daily)   &&
            norm(values.weekly)  === norm(p.weekly)  &&
            norm(values.monthly) === norm(p.monthly) &&
            norm(values.yearly)  === norm(p.yearly)) {
            return key;
        }
    }
    return 'custom';
}

class BackupPage {
    constructor() {
        this.currentTab = 'dashboard';
        this.dashboard = null;
        this.locations = null;
        this.snapshotsByLoc = {};
        this.expandedLocs = new Set();
        this.engines = [];            // [{id,name,supported_types}, ...] — fetched once
        this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
        this.eventBound = false;
    }

    async init() {
        this.currentTab = this.parseTabFromUrl() || this.currentTab;
        this.applyActiveTabUi(this.currentTab);
        this.bindEvents();
        await this.refreshAll();
        await window.Dismissible?.load();
        this.render();
        this.updatePageHeader();
        this.updatePrimaryAction();
    }

    /* Read the active tab slug from window.location, supporting both
       /backup?=dashboard (the legacy libreportal ?= form used on /config)
       and /backup?backup=dashboard (standard query string) so links from
       either source resolve correctly. */
    parseTabFromUrl() {
        const allowed = new Set(['dashboard', 'backups', 'locations', 'migrate', 'configuration']);
        // Path-based: /backup/<tab> (bare /backup → default tab).
        const seg = window.location.pathname.replace(/^\/backup\/?/, '').split('/')[0];
        if (seg && allowed.has(seg)) return seg;
        // Legacy ?=<tab> / ?backup=<tab> / ?tab=<tab> for old links.
        const search = window.location.search || '';
        const legacy = search.match(/\?=([^&]+)/);
        if (legacy && allowed.has(legacy[1])) return legacy[1];
        const params = new URLSearchParams(search);
        const q = params.get('backup') || params.get('tab');
        if (q && allowed.has(q)) return q;
        return null;
    }

    /* Toggle the sidebar .active class + panel visibility without going
       through switchTab's URL-update path (used on initial render and
       browser back/forward). */
    applyActiveTabUi(tab) {
        document.querySelectorAll('.backup-layout .sidebar .category[data-backup-tab]').forEach(b => {
            b.classList.toggle('active', b.dataset.backupTab === tab);
        });
        document.querySelectorAll('.backup-tabpanel').forEach(p => {
            p.classList.toggle('active', p.id === `backup-panel-${tab}`);
        });
    }

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

        // Browser back/forward is handled by the SPA's popstate listener —
        // pushTabToUrl includes a `route` field in state so the SPA's
        // handler picks it up and re-runs handleBackup, which re-parses
        // the URL via parseTabFromUrl() at init time.

        document.addEventListener('click', (e) => {
            // Clicking outside the export dropdown (and not on its trigger) closes it.
            const exportMenu = document.getElementById('backup-export-menu');
            if (exportMenu && !exportMenu.hidden
                && !e.target.closest('#backup-export-menu')
                && !e.target.closest('#backup-primary-action')) {
                this.toggleExportMenu(false);
            }

            const tabBtn = e.target.closest('.backup-layout .sidebar .category[data-backup-tab]');
            if (tabBtn) {
                this.switchTab(tabBtn.dataset.backupTab);
                return;
            }

            if (e.target.closest('#backup-refresh-btn')) {
                this.refreshAll().then(() => this.render());
                return;
            }

            if (e.target.closest('#backup-primary-action')) {
                this.handlePrimaryAction();
                return;
            }

            if (e.target.closest('[data-action="backup-system"]')) {
                this.runBackupSystem();
                return;
            }

            if (e.target.closest('[data-action="restore-system"]')) {
                this.confirmRestoreSystem();
                return;
            }

            const restoreBtn = e.target.closest('[data-action="restore-snapshot"]');
            if (restoreBtn) {
                this.openRestoreModal(restoreBtn.dataset.app, restoreBtn.dataset.loc, restoreBtn.dataset.snapshot);
                return;
            }

            const deleteBtn = e.target.closest('[data-action="delete-snapshot"]');
            if (deleteBtn) {
                this.openDeleteModal(deleteBtn.dataset.app, deleteBtn.dataset.loc, deleteBtn.dataset.snapshot);
                return;
            }

            const locEnable = e.target.closest('[data-action="toggle-location-enabled"]');
            if (locEnable) {
                const cb = locEnable.querySelector('input[type="checkbox"]');
                this.setLocationEnabled(parseInt(locEnable.dataset.loc, 10), cb ? cb.checked : true);
                return;
            }

            const locHeader = e.target.closest('[data-action="toggle-location"]');
            if (locHeader) {
                this.toggleLocationExpand(parseInt(locHeader.dataset.loc, 10));
                return;
            }

            const locSave = e.target.closest('[data-action="save-location"]');
            if (locSave) {
                this.saveInlineLocation(parseInt(locSave.dataset.loc, 10));
                return;
            }

            const locDelete = e.target.closest('[data-action="delete-location"]');
            if (locDelete) {
                this.deleteInlineLocation(parseInt(locDelete.dataset.loc, 10));
                return;
            }

            const sshSave = e.target.closest('[data-action="ssh-key-save"]');
            if (sshSave) { this.saveBackupSshKey(parseInt(sshSave.dataset.loc, 10)); return; }
            const sshGen = e.target.closest('[data-action="ssh-key-generate"]');
            if (sshGen) { this.generateBackupSshKey(parseInt(sshGen.dataset.loc, 10)); return; }
            const sshDel = e.target.closest('[data-action="ssh-key-delete"]');
            if (sshDel) { this.deleteBackupSshKey(parseInt(sshDel.dataset.loc, 10)); return; }
            const sshCopy = e.target.closest('[data-action="ssh-key-copy"]');
            if (sshCopy) { this.copyBackupSshKey(parseInt(sshCopy.dataset.loc, 10)); return; }

            const locTab = e.target.closest('[data-action="loc-tab"]');
            if (locTab) {
                const tabIdx = locTab.dataset.loc;
                const tabName = locTab.dataset.tab;
                const root = locTab.closest('.backup-location-config') || document;
                root.querySelectorAll(`[data-action="loc-tab"][data-loc="${tabIdx}"]`).forEach(b => {
                    const on = b === locTab;
                    b.classList.toggle('active', on);
                    b.setAttribute('aria-selected', on ? 'true' : 'false');
                });
                root.querySelectorAll(`[data-tab-panel][data-loc="${tabIdx}"]`).forEach(p => {
                    p.classList.toggle('active', p.dataset.tabPanel === tabName);
                });
                return;
            }

            if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) {
                this.closeAllModals();
                return;
            }

            const migrateAppBtn = e.target.closest('[data-action="migrate-app"]');
            if (migrateAppBtn) {
                this.openMigrateModal({
                    mode: 'app',
                    locIdx: parseInt(migrateAppBtn.dataset.loc, 10),
                    host:   migrateAppBtn.dataset.host,
                    app:    migrateAppBtn.dataset.app
                });
                return;
            }
            const migrateHostBtn = e.target.closest('[data-action="migrate-host"]');
            if (migrateHostBtn) {
                this.openMigrateModal({
                    mode:   'host',
                    locIdx: parseInt(migrateHostBtn.dataset.loc, 10),
                    host:   migrateHostBtn.dataset.host
                });
                return;
            }
            if (e.target.closest('#backup-migrate-confirm')) { this.confirmMigrate(); return; }
            if (e.target.closest('#backup-restore-confirm')) { this.confirmRestore(); return; }
            if (e.target.closest('#backup-delete-confirm'))  { this.confirmDelete();  return; }
            if (e.target.closest('#backup-add-location-confirm')) { this.confirmAddLocation(); return; }
            const engineBtn = e.target.closest('[data-action="open-engine-details"]');
            if (engineBtn) { this.openEngineDetailsModal(engineBtn); return; }

            const exportBtn = e.target.closest('[data-action="export-passwords"]');
            if (exportBtn) { this.toggleExportMenu(false); this.exportRepositoryPasswords(exportBtn); return; }

            const dismissWarn = e.target.closest('[data-action="dismiss-config-warning"]');
            if (dismissWarn) {
                window.Dismissible?.dismiss('backup-config-warning');
                const banner = dismissWarn.closest('.backup-warning-banner');
                const divider = banner?.nextElementSibling;
                if (divider && divider.classList.contains('config-divider')) divider.remove();
                banner?.remove();
                return;
            }

            const saveBtn = e.target.closest('[data-backup-save]');
            if (saveBtn) {
                this.saveSection(saveBtn.dataset.backupSave);
                return;
            }
        });

        document.addEventListener('input', (e) => {
            if (e.target.id === 'backup-snapshot-filter' || e.target.id === 'backup-snapshot-repo') {
                this.renderSnapshots();
            }
        });

        // Type select changes refresh the visible connection fields inline.
        // Retention preset changes are handled by applyRetentionPreset, which
        // already updates CUSTOM_RETENTION too — no extra toggle wiring needed.
        document.addEventListener('change', (e) => {
            const detailsScope = e.target.closest('.backup-location-row .task-details');
            if (detailsScope) {
                const locIdx = parseInt(detailsScope.dataset.loc, 10);
                if (e.target.matches('[name$="_TYPE"]')) {
                    this.refreshInlineTypeFields(locIdx, e.target.value);
                }
                if (e.target.matches('[name$="_SSH_AUTH"]')) {
                    this.applySshAuthVisibility(detailsScope);
                }
                if (e.target.matches('[name$="_PATH_MODE"]')) {
                    this.applyPathModeVisibility(detailsScope);
                }
            }
            const presetSel = e.target.closest('[data-retention-preset]');
            if (presetSel) {
                this.applyRetentionPreset(presetSel);
            }
        });
    }

    async refreshAll() {
        const ts = Date.now();
        const [dashboard, locations, , schema, migrate] = await Promise.all([
            this.fetchJson(`/data/backup/generated/dashboard.json?t=${ts}`),
            this.fetchJson(`/data/backup/generated/locations.json?t=${ts}`),
            this.loadSystemConfigs(),
            this.fetchJson(`/data/backup/generated/schema.json?t=${ts}`),
            this.fetchJson(`/data/backup/generated/migrate.json?t=${ts}`)
        ]);
        this.dashboard = dashboard;
        this.locations = locations;
        this.locSchema = schema;
        this.migrate = migrate;
        this.snapshotsByLoc = {};

        if (!this.engines.length) await this.loadEngines();

        if (locations?.locations?.length) {
            const enabled = locations.locations.filter(l => l.enabled);
            await Promise.all(enabled.map(async (l) => {
                const s = await this.fetchJson(`/data/backup/generated/snapshots_${l.idx}.json?t=${ts}`);
                if (s) this.snapshotsByLoc[l.idx] = s;
            }));
        }
    }

    async fetchJson(url) {
        try { const r = await fetch(url); if (!r.ok) return null; return await r.json(); }
        catch { return null; }
    }

    /* Load the unified config file once for the Locations editor: configData
       carries field metadata (titles/descriptions/options/advanced) the editor
       renders from; systemConfigs is the flat key->value map used for default
       lookups (e.g. CFG_BACKUP_ENGINE) and save-time change detection. */
    async loadSystemConfigs() {
        const data = await this.fetchJson(`/data/config/generated/configs.json?t=${Date.now()}`);
        if (!data) return;
        window.configData = data;
        const flat = {};
        for (const [k, v] of Object.entries(data.config || {})) flat[k] = v?.value ?? '';
        window.systemConfigs = flat;
    }

    async loadEngines() {
        const ts = Date.now();
        const index = await this.fetchJson(`/data/backup/generated/engines/index.json?t=${ts}`);
        const ids = index?.engines || [];
        const metas = await Promise.all(ids.map(id =>
            this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(id)}.json?t=${ts}`)
        ));
        this.engines = metas.filter(Boolean);
        // Fallback so the dropdown never collapses to empty if the regen
        // hasn't run yet — restic is always assumed available.
        if (!this.engines.length) {
            this.engines = [{ id: 'restic', name: 'Restic', supported_types: ['local','sftp','rest','s3','b2','gs','azure','rclone'] }];
        }
    }

    engineDisplayName(id) {
        if (!id) return 'Restic';
        const match = (this.engines || []).find(e => e.id === id);
        return match?.name || id;
    }

    enginesForType(type) {
        if (!type) return this.engines;
        return this.engines.filter(e =>
            !Array.isArray(e.supported_types) ||
            e.supported_types.includes(type)
        );
    }

    switchTab(tab, opts = {}) {
        if (!tab || tab === this.currentTab) return;
        this.currentTab = tab;
        this.applyActiveTabUi(tab);
        this.updatePageHeader();
        this.updatePrimaryAction();
        if (!opts.fromPopstate) this.pushTabToUrl(tab);
    }

    pushTabToUrl(tab) {
        const url = `/backup/${tab}`;
        // Use replaceState for the *first* push (initial tab inferred from
        // URL); otherwise pushState so back/forward navigates between tabs.
        if (!this._pushedAnyTab) {
            window.history.replaceState({ backupTab: tab, route: url }, '', url);
            this._pushedAnyTab = true;
        } else {
            window.history.pushState({ backupTab: tab, route: url }, '', url);
        }
    }

    updatePageHeader() {
        const titleEl = document.getElementById('backup-section-title');
        const subEl = document.getElementById('backup-section-subtitle');
        const iconEl = document.getElementById('backup-page-header-icon');
        if (titleEl) titleEl.textContent = this.titleFor(this.currentTab);
        if (subEl)   subEl.textContent = this.subtitleFor(this.currentTab);
        if (iconEl)  iconEl.innerHTML = this.iconFor(this.currentTab);
    }

    titleFor(tab) {
        return {
            dashboard:     'Dashboard',
            backups:       'Backups',
            locations:     'Locations',
            migrate:       'Migrate',
            configuration: 'Configuration'
        }[tab] || 'Backups';
    }

    subtitleFor(tab) {
        return {
            dashboard:     'Per-app status and storage at a glance.',
            backups:       'Every snapshot across every enabled location.',
            locations:     'Where backups are stored. Add, edit, or remove destinations.',
            migrate:       'Restore an app from another LibrePortal that shares one of your backup locations.',
            configuration: 'Schedule, retention, and engine settings.'
        }[tab] || '';
    }

    iconFor(tab) {
        const icons = {
            dashboard:
                '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
                '<rect x="3" y="3" width="7" height="9"></rect>' +
                '<rect x="14" y="3" width="7" height="5"></rect>' +
                '<rect x="14" y="12" width="7" height="9"></rect>' +
                '<rect x="3" y="16" width="7" height="5"></rect></svg>',
            backups:
                '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
                '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>' +
                '<polyline points="17 8 12 3 7 8"></polyline>' +
                '<line x1="12" y1="3" x2="12" y2="15"></line></svg>',
            locations:
                '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
                '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>' +
                '<circle cx="12" cy="10" r="3"></circle></svg>',
            migrate:
                '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
                '<path d="M3 12h18"></path>' +
                '<polyline points="12 5 19 12 12 19"></polyline>' +
                '<path d="M3 5v14"></path></svg>',
            configuration:
                '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
                '<circle cx="12" cy="12" r="3"></circle>' +
                '<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>'
        };
        return icons[tab] || icons.backups;
    }

    updatePrimaryAction() {
        const btn = document.getElementById('backup-primary-action');
        if (!btn) return;
        // Switching tabs always closes the export dropdown.
        this.toggleExportMenu(false);
        if (this.currentTab === 'locations') {
            btn.innerHTML = `
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <line x1="12" y1="5" x2="12" y2="19"></line>
                    <line x1="5" y1="12" x2="19" y2="12"></line>
                </svg>
                Add location
            `;
            btn.dataset.intent = 'add-location';
            btn.removeAttribute('aria-haspopup');
        } else if (this.currentTab === 'configuration') {
            btn.innerHTML = `
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
                    <polyline points="7 10 12 15 17 10"></polyline>
                    <line x1="12" y1="15" x2="12" y2="3"></line>
                </svg>
                Export
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <polyline points="6 9 12 15 18 9"></polyline>
                </svg>
            `;
            btn.dataset.intent = 'export-menu';
            btn.setAttribute('aria-haspopup', 'menu');
        } else {
            btn.innerHTML = `
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
                    <polyline points="17 8 12 3 7 8"></polyline>
                    <line x1="12" y1="3" x2="12" y2="15"></line>
                </svg>
                Backup all apps
            `;
            btn.dataset.intent = 'backup-all';
            btn.removeAttribute('aria-haspopup');
        }
    }

    handlePrimaryAction() {
        const intent = document.getElementById('backup-primary-action')?.dataset.intent;
        if (intent === 'add-location') {
            this.openAddLocationModal();
        } else if (intent === 'export-menu') {
            this.toggleExportMenu();
        } else {
            this.runBackupAllApps();
        }
    }

    toggleExportMenu(force) {
        const menu = document.getElementById('backup-export-menu');
        const btn = document.getElementById('backup-primary-action');
        if (!menu) return;
        const show = typeof force === 'boolean' ? force : menu.hidden;
        menu.hidden = !show;
        if (btn) btn.setAttribute('aria-expanded', show ? 'true' : 'false');
    }

    render() {
        this.renderDashboard();
        this.renderLocations();
        this.renderSnapshots();
        this.renderMigrate();
        this.renderConfiguration();
    }

    renderDashboard() {
        const summary = document.getElementById('backup-summary-row');
        const appGrid = document.getElementById('backup-app-grid');
        const locSummary = document.getElementById('backup-repo-list-summary');
        if (!summary || !appGrid || !locSummary) return;

        const d = this.dashboard || {};
        const locs = d.locations || [];
        const apps = d.apps || [];
        const totalSnapshots = Object.values(this.snapshotsByLoc).reduce((acc, r) => {
            return acc + (Array.isArray(r?.snapshots) ? r.snapshots.length : 0);
        }, 0);
        const protectedApps = apps.filter(a => a.latest_snapshot).length;
        const totalSize = locs.reduce((acc, r) => acc + (parseInt(r.total_size_bytes) || 0), 0);

        summary.innerHTML = `
            ${this.tile('Apps protected', `${protectedApps} / ${apps.length}`, 'with at least one backup')}
            ${this.tile('Backups', `${totalSnapshots}`, `across ${locs.length} location${locs.length === 1 ? '' : 's'}`)}
            ${this.tile('Total stored', this.formatBytes(totalSize), 'deduplicated, encrypted')}
        `;

        if (!apps.length) {
            appGrid.innerHTML = `<div class="backup-empty-state">No apps installed yet.</div>`;
        } else {
            appGrid.innerHTML = apps.map(app => this.renderAppTile(app)).join('');
        }

        const sysStatus = document.getElementById('backup-system-status');
        if (sysStatus) {
            const sys = d.system || {};
            const hasSys = !!sys.latest_snapshot;
            sysStatus.innerHTML = `
                <span class="backup-status-dot ${hasSys ? 'ok' : 'none'}"></span>
                <span>${hasSys ? 'Last backed up ' + this.formatRelative(sys.latest_time) : 'No backup yet'}</span>
            `;
        }

        if (!locs.length) {
            locSummary.innerHTML = `<div class="backup-empty-state">No locations enabled.</div>`;
        } else {
            locSummary.innerHTML = locs.map(r => `
                <div class="backup-repo-row">
                    <div class="backup-repo-row-name">
                        <span class="backup-repo-type-pill">${this.escape(r.type)}</span>
                        ${this.escape(r.name)}
                    </div>
                    <div class="backup-repo-row-meta">
                        ${this.formatBytes(parseInt(r.total_size_bytes) || 0)}<br>
                        <span class="backup-card-hint">${r.total_files || 0} files</span>
                    </div>
                </div>
            `).join('');
        }
    }

    tile(label, value, detail) {
        return `
            <div class="backup-summary-tile">
                <div class="backup-summary-tile-label">${this.escape(label)}</div>
                <div class="backup-summary-tile-value">${this.escape(value)}</div>
                <div class="backup-summary-tile-detail">${this.escape(detail || '')}</div>
            </div>
        `;
    }

    /* Look up the icon + display name from window.apps the same way the
       dashboard and tasks page do. Falls back to the default app icon and
       a capitalised slug if the app isn't in the cached list. */
    appMeta(slug) {
        const apps = window.apps || [];
        const match = apps.find(a => {
            const command = a.command || '';
            return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase();
        });
        let icon = match?.icon || '/icons/apps/default.svg';
        if (!icon.startsWith('/')) icon = '/' + icon;
        const displayName = (typeof window.getAppDisplayName === 'function')
            ? window.getAppDisplayName(slug)
            : (slug.charAt(0).toUpperCase() + slug.slice(1));
        return { icon, displayName };
    }

    renderAppTile(app) {
        const has = !!app.latest_snapshot;
        const dot = has ? 'ok' : 'none';
        const when = has ? this.formatRelative(app.latest_time) : 'No backup yet';
        const { icon, displayName } = this.appMeta(app.app);
        return `
            <div class="backup-app-tile" data-app="${this.escape(app.app)}">
                <img class="backup-app-tile-icon" src="${this.escape(icon)}" alt="" onerror="this.src='/icons/apps/default.svg'">
                <div class="backup-app-tile-text">
                    <div class="backup-app-tile-name">${this.escape(displayName)}</div>
                    <div class="backup-app-tile-meta">
                        <span class="backup-status-dot ${dot}"></span>
                        <span>${when}</span>
                    </div>
                </div>
            </div>
        `;
    }

    renderLocations() {
        const list = document.getElementById('backup-location-list');
        const repoSelect = document.getElementById('backup-snapshot-repo');
        if (!list) return;

        const locs = this.locations?.locations || [];
        if (!locs.length) {
            list.innerHTML = `
                <div class="backup-empty-state">
                    No backup locations configured yet.<br>
                    Click <strong>Add location</strong> above to create one.
                </div>
            `;
        } else {
            list.innerHTML = locs.map(l => this.renderLocationRow(l)).join('');
        }

        if (repoSelect) {
            const cur = repoSelect.value;
            repoSelect.innerHTML = `<option value="">All locations</option>` +
                locs.filter(l => l.enabled).map(l => `<option value="${l.idx}">${this.escape(l.name)}</option>`).join('');
            if (cur) repoSelect.value = cur;
        }
    }

    renderLocationRow(l) {
        // Status pill mirrors task-status: ✅ Ready / ⏳ Initialising / ⏸ Disabled.
        const statusKind = l.enabled && l.password_exists ? 'ready'
                         : l.enabled && !l.password_exists ? 'init'
                         : 'disabled';
        const statusMeta = {
            ready:    { icon: '✅', label: 'Ready'        },
            init:     { icon: '⏳', label: 'Initialising' },
            disabled: { icon: '⏸',  label: 'Disabled'     }
        }[statusKind];
        const snapCount = this.snapshotsByLoc[l.idx]?.snapshots?.length ?? 0;
        const expanded = this.expandedLocs.has(l.idx);
        const size = this.formatBytes(parseInt(l.total_size_bytes) || 0);
        return `
            <div class="task-item backup-location-row" data-loc="${l.idx}">
                <div class="task-header backup-location-header" data-action="toggle-location" data-loc="${l.idx}" aria-expanded="${expanded ? 'true' : 'false'}">
                    <div class="task-info backup-location-row-info">
                        <span class="backup-location-row-type-icon" data-type="${this.escape(l.type)}">${this.typeIcon(l.type)}</span>
                        <span class="backup-location-row-name">${this.escape(l.name)}</span>
                        <span class="backup-repo-type-pill">${this.escape(l.type)}</span>
                        <span class="backup-engine-pill" data-engine="${this.escape(l.engine || 'restic')}">${this.escape(this.engineDisplayName(l.engine))}</span>
                        ${l.append_only ? '<span class="backup-pill-mini">append-only</span>' : ''}
                        <span class="task-status backup-loc-status status-${statusKind}">${statusMeta.icon} ${statusMeta.label}</span>
                        <span class="backup-location-row-sep">·</span>
                        <span class="backup-location-row-stat">${snapCount} backup${snapCount === 1 ? '' : 's'}</span>
                        <span class="backup-location-row-sep">·</span>
                        <span class="backup-location-row-stat">${size}</span>
                    </div>
                    <span class="backup-toggle backup-loc-enable-toggle" data-action="toggle-location-enabled" data-loc="${l.idx}" title="${l.enabled ? 'Enabled — click to disable this location' : 'Disabled — click to enable this location'}">
                        <input type="checkbox" ${l.enabled ? 'checked' : ''} aria-label="Enable this location">
                        <span class="backup-toggle-slider"></span>
                    </span>
                    <svg class="backup-location-chevron" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                        <polyline points="6 9 12 15 18 9"></polyline>
                    </svg>
                </div>
                <div class="task-details backup-location-details ${expanded ? 'show' : ''}" data-loc="${l.idx}">
                    ${expanded ? this.renderLocationDetailsBody(l) : ''}
                </div>
            </div>
        `;
    }

    /* Inline-SVG icon for a location's backend type. Local gets the disk
       (stack of platters) glyph; everything else gets a cloud — that's
       the visual line between "lives on this box" and "lives somewhere else." */
    typeIcon(type) {
        const local = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
            <path d="M3 5v6c0 1.66 4.03 3 9 3s9-1.34 9-3V5"></path>
            <path d="M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"></path>
        </svg>`;
        const cloud = `<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="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path>
        </svg>`;
        return type === 'local' ? local : cloud;
    }

    renderLocationDetailsBody(l) {
        const idx = l.idx;
        const groups = this.locFieldGroups(idx, l.type);
        const retentionValues = {
            last:    l.custom_retention ? (l.keep_last    || '') : '',
            daily:   l.custom_retention ? (l.keep_daily   || '') : '',
            weekly:  l.custom_retention ? (l.keep_weekly  || '') : '',
            monthly: l.custom_retention ? (l.keep_monthly || '') : '',
            yearly:  l.custom_retention ? (l.keep_yearly  || '') : ''
        };

        // Reuse the app-detail tab design (.tabs-wrapper/.tab-button/.tab-panel
        // from style.css) so the Locations editor matches the rest of the UI.
        const tab = (id, emoji, label) => `
            <button type="button" class="tab-button${id === 'connection' ? ' active' : ''}" data-action="loc-tab" data-loc="${idx}" data-tab="${id}" role="tab" aria-selected="${id === 'connection'}">
                <span class="tab-emoji">${emoji}</span>
                <span class="tab-name">${label}</span>
            </button>`;

        return `
            <div class="config-category backup-location-config" data-section="location-${idx}">
                <div class="tabs-wrapper">
                    <div class="tabs-list" role="tablist">
                        ${tab('connection', '🔗', 'Connection')}
                        ${tab('retention', '♻️', 'Retention')}
                        ${tab('advanced', '⚙️', 'Advanced')}
                    </div>
                    <div class="tabs-content">
                        <div class="tab-panel active" data-tab-panel="connection" data-loc="${idx}">
                            <div class="backup-location-connection-fields" id="backup-location-${idx}-connection">
                                ${this.renderConnectionInner(idx, l.type, l, groups.connection)}
                            </div>
                        </div>
                        <div class="tab-panel" data-tab-panel="retention" data-loc="${idx}">
                            <div id="backup-location-${idx}-retention">
                                ${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, retentionValues, true)}
                            </div>
                        </div>
                        <div class="tab-panel" data-tab-panel="advanced" data-loc="${idx}">
                            <div id="backup-location-${idx}-advanced">
                                ${this.renderLocFields(idx, groups.advanced, l)}
                            </div>
                        </div>
                    </div>
                </div>
            </div>
            <div class="backup-location-actions">
                <button class="backup-primary-btn" data-action="save-location" data-loc="${idx}">
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                        <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
                        <polyline points="17 21 17 13 7 13 7 21"></polyline>
                        <polyline points="7 3 7 8 15 8"></polyline>
                    </svg>
                    Save changes
                </button>
                <button class="backup-danger-btn" data-action="delete-location" data-loc="${idx}">
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                        <polyline points="3 6 5 6 21 6"></polyline>
                        <path d="m19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
                        <line x1="10" y1="11" x2="10" y2="17"></line>
                        <line x1="14" y1="11" x2="14" y2="17"></line>
                    </svg>
                    Delete location
                </button>
            </div>
        `;
    }

    toggleLocationExpand(idx) {
        const row = document.querySelector(`.backup-location-row[data-loc="${idx}"]`);
        if (!row) return;
        const details = row.querySelector('.task-details');
        const header = row.querySelector('.task-header');
        if (!details) return;

        const willOpen = !this.expandedLocs.has(idx);
        if (willOpen) {
            this.expandedLocs.add(idx);
            const loc = (this.locations?.locations || []).find(l => l.idx === idx);
            if (loc) {
                details.innerHTML = this.renderLocationDetailsBody(loc);
                this.tagFieldsForSave(details);
                this.filterEngineSelect(details, loc.type, loc.engine);
                this.applySshAuthVisibility(details);
                this.applyPathModeVisibility(details);
            }
            this.enhanceEngineDetailsButton();
            details.classList.add('show');
            row.classList.add('expanded');
            if (header) header.setAttribute('aria-expanded', 'true');
        } else {
            this.expandedLocs.delete(idx);
            details.classList.remove('show');
            row.classList.remove('expanded');
            if (header) header.setAttribute('aria-expanded', 'false');
        }
    }

    refreshInlineTypeFields(idx, type) {
        const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {};
        const groups = this.locFieldGroups(idx, type);

        const conn = document.getElementById(`backup-location-${idx}-connection`);
        if (conn) {
            conn.innerHTML = this.renderConnectionInner(idx, type, { ...loc, type }, groups.connection);
            this.tagFieldsForSave(conn);
        }

        // The Advanced tab's fields are type-dependent too (URI override only
        // applies to some types), so rebuild it alongside the Connection tab.
        const adv = document.getElementById(`backup-location-${idx}-advanced`);
        if (adv) {
            adv.innerHTML = this.renderLocFields(idx, groups.advanced, { ...loc, type });
            this.tagFieldsForSave(adv);
        }

        // Re-apply dynamic behaviors across the whole details scope: the engine
        // select lives in the Advanced tab while SSH-auth / path-mode live in
        // Connection, so target the shared parent rather than one panel.
        const scope = (conn || adv)?.closest('.task-details');
        if (scope) {
            this.filterEngineSelect(scope, type, loc.engine);
            this.applySshAuthVisibility(scope);
            this.applyPathModeVisibility(scope);
        }
        this.enhanceEngineDetailsButton();
    }

    /* Hide the SSH password field when SSH auth = key, show it when = password.
       Applied at expand time and whenever the SSH_AUTH select changes. */
    applySshAuthVisibility(scope) {
        const authSelect = scope.querySelector('select[name$="_SSH_AUTH"]');
        if (!authSelect) return;
        const isPassword = authSelect.value === 'password';
        const passInput = scope.querySelector('input[name$="_SSH_PASS"]');
        const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement;
        if (passGroup) passGroup.style.display = isPassword ? '' : 'none';
        // SSH key card is the counterpart: shown for key auth, hidden for password.
        const keyCard = scope.querySelector('.backup-ssh-key-card');
        if (keyCard) keyCard.style.display = isPassword ? 'none' : '';
    }

    /* Hide the custom PATH input when PATH_MODE=auto, show when =custom. */
    applyPathModeVisibility(scope) {
        const modeSelect = scope.querySelector('select[name$="_PATH_MODE"]');
        if (!modeSelect) return;
        const pathInput = scope.querySelector('input[name$="_PATH"]:not([name$="_SSH_PATH"])');
        const pathGroup = pathInput?.closest('.field-group') || pathInput?.parentElement;
        if (!pathGroup) return;
        pathGroup.style.display = modeSelect.value === 'custom' ? '' : 'none';
    }

    /* Trim the per-location ENGINE select to only engines whose
       supported_types include the location's current TYPE. If the currently
       saved engine isn't compatible, fall back to the first compatible one. */
    filterEngineSelect(scope, type, preferred) {
        const select = scope.querySelector('select[name$="_ENGINE"]');
        if (!select) return;
        const compatible = this.enginesForType(type);
        if (!compatible.length) return;

        // Float the system-default engine (CFG_BACKUP_ENGINE) to the top and
        // tag it "(default)" so it's the obvious pick for new locations.
        const defaultId = (window.systemConfigs?.CFG_BACKUP_ENGINE || 'restic').trim();
        const rank = e => (e.id === defaultId ? 0 : 1);
        const ordered = [...compatible].sort((a, b) => rank(a) - rank(b));

        const want = ordered.find(e => e.id === preferred)?.id || ordered[0].id;
        select.innerHTML = ordered
            .map(e => {
                const label = (e.name || e.id) + (e.id === defaultId ? ' (default)' : '');
                return `<option value="${this.escape(e.id)}" ${e.id === want ? 'selected' : ''}>${this.escape(label)}</option>`;
            })
            .join('');
        select.value = want;
    }

    async saveInlineLocation(idx) {
        await this.saveSection(`location-${idx}`);
    }

    async deleteInlineLocation(idx) {
        const loc = (this.locations?.locations || []).find(l => l.idx === idx);
        const name = loc?.name || `Location ${idx}`;
        if (!confirm(`Delete location "${name}"?\n\nBackup data already stored at this location is not deleted — only LibrePortal's reference to it. The password file on disk also stays in place.`)) return;
        this.expandedLocs.delete(idx);
        await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null);
        setTimeout(() => this.reloadAfterSave(), 2000);
    }

    renderSnapshots() {
        const tbody = document.getElementById('backup-snapshot-tbody');
        if (!tbody) return;

        const filter = (document.getElementById('backup-snapshot-filter')?.value || '').toLowerCase();
        const locFilter = document.getElementById('backup-snapshot-repo')?.value || '';

        const locNameByIdx = {};
        (this.locations?.locations || []).forEach(l => { locNameByIdx[l.idx] = l.name; });

        const rows = [];
        Object.entries(this.snapshotsByLoc).forEach(([locIdx, data]) => {
            if (locFilter && String(locFilter) !== String(locIdx)) return;
            const snaps = Array.isArray(data?.snapshots) ? data.snapshots : [];
            snaps.forEach(s => {
                const app = (s.tags || []).map(t => /^app=/.test(t) ? t.slice(4) : null).find(Boolean) || '—';
                rows.push({
                    app,
                    host: s.hostname || '—',
                    locIdx,
                    locName: locNameByIdx[locIdx] || `Loc ${locIdx}`,
                    time: s.time,
                    id: s.short_id || (s.id || '').slice(0, 8),
                });
            });
        });

        rows.sort((a, b) => String(b.time).localeCompare(String(a.time)));

        const filtered = filter ? rows.filter(r =>
            r.app.toLowerCase().includes(filter) ||
            r.host.toLowerCase().includes(filter) ||
            r.id.toLowerCase().includes(filter) ||
            r.locName.toLowerCase().includes(filter)
        ) : rows;

        if (!filtered.length) {
            tbody.innerHTML = `<tr><td colspan="6" class="backup-empty-state">No backups yet.</td></tr>`;
            return;
        }

        tbody.innerHTML = filtered.map(r => `
            <tr>
                <td>${this.escape(r.app)}</td>
                <td>${this.escape(r.host)}</td>
                <td>${this.escape(r.locName)}</td>
                <td>${this.formatRelative(r.time)}</td>
                <td class="backup-snapshot-id">${this.escape(r.id)}</td>
                <td class="backup-col-actions">
                    <button class="backup-row-action-btn" data-action="restore-snapshot" data-app="${this.escape(r.app)}" data-loc="${r.locIdx}" data-snapshot="${this.escape(r.id)}">Restore</button>
                    <button class="backup-row-action-btn danger" data-action="delete-snapshot" data-app="${this.escape(r.app)}" data-loc="${r.locIdx}" data-snapshot="${this.escape(r.id)}">Delete</button>
                </td>
            </tr>
        `).join('');
    }

    renderConfiguration() {
        const body = document.getElementById('backup-configuration-body');
        if (!body) return;

        // Dismissed state is persisted server-side via Dismissible
        // (data/ui-state.json), so it follows the user across browsers/devices.
        // Banner + its divider are omitted entirely once dismissed.
        const warningDismissed = !!window.Dismissible?.isDismissed('backup-config-warning');
        const warningHTML = warningDismissed ? '' : `
            <div class="backup-warning-banner">
                <svg class="backup-warning-banner-icon" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                    <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
                    <line x1="12" y1="9" x2="12" y2="13"></line>
                    <line x1="12" y1="17" x2="12.01" y2="17"></line>
                </svg>
                <div class="backup-warning-banner-text">
                    <strong>Keep your LibrePortal config backed up offline.</strong>
                    <span>Repository passwords live inside the config directory. Without that backup, snapshots cannot be decrypted by anyone — including you.</span>
                </div>
                <button type="button" class="backup-warning-banner-close" data-action="dismiss-config-warning" title="Dismiss this warning" aria-label="Dismiss this warning">
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                        <line x1="18" y1="6" x2="6" y2="18"></line>
                        <line x1="6" y1="6" x2="18" y2="18"></line>
                    </svg>
                </button>
            </div>
            <div class="config-divider"></div>
        `;

        body.innerHTML = `
            ${warningHTML}
            <div id="config-section" class="backup-embedded-config"></div>
        `;

        this.invokeConfigManager();
    }

    async exportRepositoryPasswords(triggerBtn) {
        const restoreBtn = () => {
            if (triggerBtn) {
                triggerBtn.disabled = false;
                triggerBtn.dataset.busy = '';
            }
        };
        if (triggerBtn) {
            triggerBtn.disabled = true;
            triggerBtn.dataset.busy = '1';
        }

        try {
            const task = await this.taskManager?.createTask(
                'libreportal webui generate backup',
                'webui',
                null
            );
            if (task?.id) {
                await this.waitForTask(task.id, 20000);
            }
            const res = await fetch(`/data/backup/generated/passwords.txt?t=${Date.now()}`, {
                credentials: 'same-origin'
            });
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            const text = await res.text();
            if (!text || !text.includes('CFG_BACKUP_LOC_')) {
                throw new Error('Password file is empty — no locations configured?');
            }
            const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            const host = (window.systemConfigs?.CFG_INSTALL_NAME || 'libreportal').replace(/[^a-z0-9_-]/gi, '_');
            const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
            a.href = url;
            a.download = `libreportal-backup-passwords-${host}-${stamp}.txt`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
            this.notify('Password export downloaded — store it offline.', 'success');
        } catch (err) {
            this.notify(`Export failed: ${err.message || err}`, 'error');
        } finally {
            restoreBtn();
        }
    }

    waitForTask(taskId, timeoutMs = 15000) {
        return new Promise((resolve) => {
            let done = false;
            const finish = () => {
                if (done) return;
                done = true;
                window.removeEventListener('taskCompleted', onComplete);
                clearTimeout(timer);
                resolve();
            };
            const onComplete = (e) => {
                if (e?.detail?.taskId === taskId) finish();
            };
            window.addEventListener('taskCompleted', onComplete);
            const timer = setTimeout(finish, timeoutMs);
        });
    }

    async invokeConfigManager(attempt = 0) {
        if (window.configManager && typeof window.configManager.renderConfig === 'function') {
            try {
                await window.configManager.renderConfig('backup');
                this.enhanceConfigurationWithPresets();
            } catch (err) {
                console.error('Backup configuration render failed:', err);
            }
            return;
        }
        if (attempt >= 20) {
            const sec = document.getElementById('config-section');
            if (sec) sec.innerHTML = `<div class="backup-empty-state">Configuration system not loaded. Try refreshing the page.</div>`;
            return;
        }
        setTimeout(() => this.invokeConfigManager(attempt + 1), 150);
    }

    /* Post-render polish on the dynamic /config render: wrap the five raw
       retention number fields in a persona-preset dropdown. The five inputs
       stay in the DOM (so /config's save flow captures them unchanged) but
       are hidden under "Custom…" by default. */
    enhanceConfigurationWithPresets() {
        this.enhanceEngineDetailsButton();
        const lastInput = document.querySelector('#config-section [name="CFG_BACKUP_KEEP_LAST"]');
        if (!lastInput) return;

        const section = lastInput.closest('.config-category');
        if (!section || section.dataset.backupPresetEnhanced === '1') return;
        section.dataset.backupPresetEnhanced = '1';

        const fieldNames = [
            'CFG_BACKUP_KEEP_LAST',
            'CFG_BACKUP_KEEP_DAILY',
            'CFG_BACKUP_KEEP_WEEKLY',
            'CFG_BACKUP_KEEP_MONTHLY',
            'CFG_BACKUP_KEEP_YEARLY'
        ];
        const inputs = fieldNames
            .map(n => section.querySelector(`[name="${n}"]`))
            .filter(Boolean);
        if (inputs.length < 5) return;

        const wrappers = inputs.map(input => {
            return input.closest('.config-field, .field-group, .form-group') || input.parentElement;
        });

        const extraCustomFields = ['CFG_BACKUP_PRUNE_AFTER_FORGET'];
        extraCustomFields.forEach(name => {
            const el = section.querySelector(`[name="${name}"]`);
            if (el) {
                const wrap = el.closest('.config-field, .field-group, .form-group') || el.parentElement;
                if (wrap) wrappers.push(wrap);
            }
        });

        const readVals = () => ({
            last:    inputs[0].value || '',
            daily:   inputs[1].value || '',
            weekly:  inputs[2].value || '',
            monthly: inputs[3].value || '',
            yearly:  inputs[4].value || ''
        });

        const preset = backupRetentionDetectPreset(readVals());
        const meta = BACKUP_RETENTION_PRESET_META[preset];
        const presetOptions = this.retentionPresetOptions(preset, false);

        const block = document.createElement('div');
        block.className = 'backup-retention-preset-block';
        block.innerHTML = `
            <label class="backup-form-row">
                <span class="backup-form-label">Backup style <span class="tooltip" data-retention-tooltip title="${this.escape(meta?.hint || '')}">ℹ️</span></span>
                <select class="form-control" data-backup-retention-preset>${presetOptions}</select>
            </label>
        `;

        const fieldsGrid = section.querySelector('.config-fields');
        if (fieldsGrid) {
            fieldsGrid.prepend(block);
        } else {
            section.prepend(block);
        }

        const applyVisibility = (presetKey) => {
            const isCustom = presetKey === 'custom';
            wrappers.forEach(w => { if (w) w.style.display = isCustom ? '' : 'none'; });
        };
        applyVisibility(preset);

        const select = block.querySelector('[data-backup-retention-preset]');
        const tooltipEl = block.querySelector('[data-retention-tooltip]');
        select.addEventListener('change', () => {
            const chosen = select.value;
            if (tooltipEl) tooltipEl.title = BACKUP_RETENTION_PRESET_META[chosen]?.hint || '';
            applyVisibility(chosen);
            if (chosen === 'custom') return;
            const p = BACKUP_RETENTION_PRESETS[chosen];
            const map = { last: 0, daily: 1, weekly: 2, monthly: 3, yearly: 4 };
            Object.entries(map).forEach(([k, i]) => {
                inputs[i].value = p[k];
                inputs[i].dispatchEvent(new Event('input', { bubbles: true }));
                inputs[i].dispatchEvent(new Event('change', { bubbles: true }));
            });
        });
    }

    /* Build the <option> list for a "Backup style" dropdown. The scope's
       default preset is tagged "(default)" and floated to the top; the rest
       follow in declared order. inherit-global only makes sense per-location
       (there's nothing to inherit at the global level), so it's omitted when
       includeInherit is false. */
    retentionPresetOptions(selected, includeInherit = false) {
        const defaultKey = includeInherit ? 'inherit-global' : 'self-hosting';
        const keys = Object.keys(BACKUP_RETENTION_PRESET_META)
            .filter(k => k !== 'inherit-global' || includeInherit);
        const ordered = [defaultKey, ...keys.filter(k => k !== defaultKey)];
        return ordered.map(k => {
            const base = BACKUP_RETENTION_PRESET_META[k].label;
            const label = k === defaultKey ? `${base} (default)` : base;
            return `<option value="${k}" ${k === selected ? 'selected' : ''}>${this.escape(label)}</option>`;
        }).join('');
    }

    /* Retention preset dropdown + hidden underlying fields.
       `prefix` is the CFG name prefix, e.g. 'CFG_BACKUP_' or 'CFG_BACKUP_LOC_3_'.
       When `includeInherit` is true (per-location scope), an "Inherit global"
       option is added at the top and an extra hidden CUSTOM_RETENTION field is
       written: false when inherit, true otherwise. The five raw KEEP_* inputs
       are always rendered (so the save flow captures them) but hidden until
       "Custom…" is selected. */
    formRetention(prefix, values, includeInherit = false) {
        const preset = backupRetentionDetectPreset(values, includeInherit);
        const meta = BACKUP_RETENTION_PRESET_META[preset];
        const presetOptions = this.retentionPresetOptions(preset, includeInherit);

        const customRetentionHidden = includeInherit
            ? `<input type="hidden" name="${prefix}CUSTOM_RETENTION" value="${preset === 'inherit-global' ? 'false' : 'true'}" data-backup-field>`
            : '';

        return `
            <div class="backup-form-grid backup-retention-block" data-retention-prefix="${this.escape(prefix)}" data-retention-allow-inherit="${includeInherit ? '1' : '0'}">
                <label class="backup-form-row">
                    <span class="backup-form-label">Backup style <span class="tooltip" data-retention-tooltip title="${this.escape(meta?.hint || '')}">ℹ️</span></span>
                    <select class="form-control" data-retention-preset>${presetOptions}</select>
                </label>
                ${customRetentionHidden}
            </div>
            <div class="backup-retention-advanced" data-retention-advanced ${preset === 'custom' ? '' : 'hidden'}>
                <div class="backup-form-grid">
                    ${this.formInput(`${prefix}KEEP_LAST`,    'Keep last',    values.last,    'number', '', 'snapshots')}
                    ${this.formInput(`${prefix}KEEP_DAILY`,   'Keep daily',   values.daily,   'number', '', 'days')}
                    ${this.formInput(`${prefix}KEEP_WEEKLY`,  'Keep weekly',  values.weekly,  'number', '', 'weeks')}
                    ${this.formInput(`${prefix}KEEP_MONTHLY`, 'Keep monthly', values.monthly, 'number', '', 'months')}
                    ${this.formInput(`${prefix}KEEP_YEARLY`,  'Keep yearly',  values.yearly,  'number', '', 'years')}
                </div>
            </div>
        `;
    }

    applyRetentionPreset(selectEl) {
        const block = selectEl.closest('[data-retention-prefix]');
        const advanced = block?.nextElementSibling;
        if (!block) return;
        const prefix = block.dataset.retentionPrefix;
        const allowInherit = block.dataset.retentionAllowInherit === '1';
        const preset = selectEl.value;
        const tooltipEl = block.querySelector('[data-retention-tooltip]');
        if (tooltipEl) tooltipEl.title = BACKUP_RETENTION_PRESET_META[preset]?.hint || '';

        if (preset === 'custom') {
            if (advanced) advanced.hidden = false;
        } else {
            if (advanced) advanced.hidden = true;
            const p = BACKUP_RETENTION_PRESETS[preset];
            if (p) {
                const setField = (suffix, value) => {
                    const el = document.querySelector(`[name="${prefix}${suffix}"]`);
                    if (el) {
                        el.value = value;
                        el.dispatchEvent(new Event('input', { bubbles: true }));
                    }
                };
                setField('KEEP_LAST',    p.last);
                setField('KEEP_DAILY',   p.daily);
                setField('KEEP_WEEKLY',  p.weekly);
                setField('KEEP_MONTHLY', p.monthly);
                setField('KEEP_YEARLY',  p.yearly);
            }
        }

        // Keep CUSTOM_RETENTION in sync with the preset (location scope only).
        if (allowInherit) {
            const cr = block.querySelector(`[name="${prefix}CUSTOM_RETENTION"]`);
            if (cr) cr.value = preset === 'inherit-global' ? 'false' : 'true';
        }
    }

    formInput(name, label, value, type = 'text', placeholder = '', unit = '') {
        const escVal = this.escape(value ?? '');
        const escPh = this.escape(placeholder);
        const escLabel = this.escape(label);
        const inputHTML = `<input type="${type}" name="${name}" value="${escVal}" placeholder="${escPh}" class="form-control" data-backup-field>`;
        const wrapped = unit ? `<div class="input-group">${inputHTML}<span class="input-group-text">${this.escape(unit)}</span></div>` : inputHTML;
        return `
            <label class="backup-form-row">
                <span class="backup-form-label">${escLabel}</span>
                ${wrapped}
            </label>
        `;
    }

    formSelect(name, label, value, options) {
        const escLabel = this.escape(label);
        const opts = options.map(([v, lbl]) => `<option value="${this.escape(v)}" ${v === value ? 'selected' : ''}>${this.escape(lbl)}</option>`).join('');
        return `
            <label class="backup-form-row">
                <span class="backup-form-label">${escLabel}</span>
                <select name="${name}" class="form-control" data-backup-field>${opts}</select>
            </label>
        `;
    }

    formToggle(name, label, checked) {
        const escLabel = this.escape(label);
        return `
            <label class="backup-form-row backup-form-row-toggle">
                <span class="backup-form-label">${escLabel}</span>
                <span class="backup-toggle">
                    <input type="checkbox" name="${name}" ${checked ? 'checked' : ''} data-backup-field data-backup-bool>
                    <span class="backup-toggle-slider"></span>
                </span>
            </label>
        `;
    }

    /* Append a "Details" button next to every Engine field (global or
       per-location). The button reads its engine id from a sibling input
       at click time so per-location selects work even before save. */
    enhanceEngineDetailsButton() {
        const selector = '[name="CFG_BACKUP_ENGINE"], [name^="CFG_BACKUP_LOC_"][name$="_ENGINE"]';
        document.querySelectorAll(`#config-section ${selector}, .backup-location-details ${selector}`).forEach((engineInput) => {
            const customSelect = engineInput.closest('.custom-select');
            const wrapTarget = customSelect || engineInput;
            const group = wrapTarget.closest('.field-group') || wrapTarget.parentElement;
            if (!group || group.dataset.engineDetailsBound === '1') return;
            group.dataset.engineDetailsBound = '1';

            const btn = document.createElement('button');
            btn.type = 'button';
            btn.className = 'backup-secondary-btn backup-engine-details-btn';
            btn.dataset.action = 'open-engine-details';
            btn.innerHTML = `
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <circle cx="12" cy="12" r="10"></circle>
                    <line x1="12" y1="16" x2="12" y2="12"></line>
                    <line x1="12" y1="8" x2="12.01" y2="8"></line>
                </svg>
                Details
            `;

            const wrap = document.createElement('div');
            wrap.className = 'backup-engine-input-row';
            wrapTarget.parentNode.insertBefore(wrap, wrapTarget);
            wrap.appendChild(wrapTarget);
            wrap.appendChild(btn);
        });
    }

    async openEngineDetailsModal(triggerEl) {
        const modal = document.getElementById('backup-engine-modal');
        const body = document.getElementById('backup-engine-modal-body');
        const title = document.getElementById('backup-engine-modal-title');
        if (!modal || !body) return;

        // Find the engine select adjacent to the Details button that fired
        // this event so per-location Details work even when the user has
        // changed the select but not saved yet.
        let engineId = (window.systemConfigs?.CFG_BACKUP_ENGINE || 'restic').trim();
        const row = triggerEl?.closest('.backup-engine-input-row');
        const sel = row?.querySelector('select, input');
        if (sel && sel.value) engineId = sel.value.trim();
        body.innerHTML = `<div class="backup-empty-state">Loading engine details…</div>`;
        modal.classList.add('open');

        const data = await this.fetchJson(`/data/backup/generated/engines/${encodeURIComponent(engineId)}.json?t=${Date.now()}`);
        if (!data) {
            body.innerHTML = `
                <div class="backup-empty-state">
                    No details file for engine "<strong>${this.escape(engineId)}</strong>".<br>
                    Add <code>scripts/backup/engines/${this.escape(engineId)}.json</code> and run the WebUI regen.
                </div>
            `;
            return;
        }

        if (title) title.textContent = `Backup engine: ${data.name || engineId}`;
        const propsHTML = (data.properties || []).map(p =>
            `<tr><th>${this.escape(p.label)}</th><td>${this.escape(p.value)}</td></tr>`
        ).join('');
        const featsHTML = (data.features || []).map(f => `<li>${this.escape(f)}</li>`).join('');
        const docsHTML = data.docs_url
            ? `<a href="${this.escape(data.docs_url)}" target="_blank" rel="noopener noreferrer" class="backup-engine-docs-link">${this.escape(data.docs_url)} ↗</a>`
            : '';
        const logoHTML = data.logo
            ? `<img class="backup-engine-logo" src="${this.escape(data.logo)}" alt="" onerror="this.style.display='none'">`
            : '';

        body.innerHTML = `
            <div class="backup-engine-modal-head">
                ${logoHTML}
                <div>
                    <h4>${this.escape(data.name || engineId)}</h4>
                    <p class="backup-card-hint">${this.escape(data.tagline || '')}</p>
                </div>
            </div>
            ${propsHTML ? `<table class="backup-engine-props">${propsHTML}</table>` : ''}
            ${featsHTML ? `<h5>Highlights</h5><ul class="backup-engine-features">${featsHTML}</ul>` : ''}
            ${docsHTML ? `<h5>Documentation</h5><p>${docsHTML}</p>` : ''}
        `;
    }

    formCrontab(name, label, value) {
        if (typeof ConfigShared === 'undefined' || !ConfigShared.createCrontabField) {
            return this.formInput(name, label, value, 'text', 'minute hour day month weekday');
        }
        const fieldId = `config-${name}`;
        let cronHtml = ConfigShared.createCrontabField(fieldId, name, value, label, '');
        cronHtml = cronHtml.replace(`name="${name}"`, `name="${name}" data-backup-field`);
        return `
            <label class="backup-form-row">
                <span class="backup-form-label">${this.escape(label)}</span>
                ${cronHtml}
            </label>
        `;
    }

    formReadOnly(label, value) {
        return `
            <div class="backup-form-row">
                <span class="backup-form-label">${this.escape(label)}</span>
                <span class="backup-form-readonly">${this.escape(value)}</span>
            </div>
        `;
    }

    /* ----- Location modal (edit / add) ----- */

    openLocationModal_unused(idx) {
        const loc = (this.locations?.locations || []).find(l => l.idx === idx);
        if (!loc) return;

        const modal = document.getElementById('backup-location-modal');
        const body = document.getElementById('backup-location-modal-body');
        const title = document.getElementById('backup-location-modal-title');
        if (!modal || !body) return;

        modal.dataset.locIdx = idx;
        title.textContent = `Edit location: ${loc.name}`;

        body.innerHTML = `
            <div class="config-category backup-location-config" data-section="location-${idx}">
                <div id="backup-location-connection"></div>
            </div>
            <div class="config-category backup-location-config">
                <h3>Retention</h3>
                <p class="category-description">When to delete old backups from this location.</p>
                <div id="backup-location-retention"></div>
            </div>
        `;

        this.refreshLocationModalTypeFields(loc.type, loc);
        this.refreshLocationModalRetention(loc.custom_retention);

        modal.classList.add('open');
    }

    refreshLocationModalTypeFields(type, locOverride) {
        const container = document.getElementById('backup-location-connection');
        const modal = document.getElementById('backup-location-modal');
        if (!container || !modal) return;
        const idx = parseInt(modal.dataset.locIdx, 10);
        const loc = locOverride || (this.locations?.locations || []).find(l => l.idx === idx) || {};

        const suffixes = this.locFieldsForType(type);
        container.innerHTML = this.renderLocFields(idx, suffixes, loc);
        this.tagFieldsForSave(container);
    }

    refreshLocationModalRetention(enabled) {
        const container = document.getElementById('backup-location-retention');
        const modal = document.getElementById('backup-location-modal');
        if (!container || !modal) return;
        const idx = parseInt(modal.dataset.locIdx, 10);
        const loc = (this.locations?.locations || []).find(l => l.idx === idx) || {};

        // The "Use custom retention" toggle itself stays at the top regardless.
        const toggleField = this.renderLocFields(idx, ['CUSTOM_RETENTION'], loc);

        if (!enabled) {
            container.innerHTML = `
                ${toggleField}
                <div class="backup-card-hint" style="margin-top:8px">Inherits the <strong>global retention policy</strong> from the Configuration tab.</div>
            `;
            this.tagFieldsForSave(container);
            return;
        }

        const values = {
            last:    loc.keep_last    || '',
            daily:   loc.keep_daily   || '',
            weekly:  loc.keep_weekly  || '',
            monthly: loc.keep_monthly || '',
            yearly:  loc.keep_yearly  || ''
        };
        container.innerHTML = `
            ${toggleField}
            ${this.formRetention(`CFG_BACKUP_LOC_${idx}_`, values)}
        `;
        this.tagFieldsForSave(container);
    }

    /* Render a list of CFG_BACKUP_LOC_${idx}_${suffix} fields via the same
       ConfigShared.generateField machinery /config uses, so widgets and
       styling match pixel-for-pixel. Values are picked up from the location
       object (locations.json) using the camelCase mirrors of each suffix. */
    renderLocFields(idx, suffixes, loc) {
        if (typeof ConfigShared === 'undefined' || !ConfigShared.generateField) {
            return `<div class="backup-empty-state">Configuration system not loaded.</div>`;
        }
        const locValueLookup = {
            NAME: loc.name, ENABLED: loc.enabled ? 'true' : 'false', TYPE: loc.type,
            ENGINE: loc.engine || 'restic',
            PATH_MODE: loc.path_mode || 'custom',
            PATH: loc.path, URI: loc.uri, SSH_USER: loc.ssh_user, SSH_HOST: loc.ssh_host,
            SSH_PORT: loc.ssh_port, SSH_PATH: loc.ssh_path,
            SSH_AUTH: loc.ssh_auth || 'key', SSH_PASS: '',
            S3_ACCESS_KEY: '', S3_SECRET_KEY: '',
            B2_ACCOUNT_ID: '', B2_ACCOUNT_KEY: '',
            APPEND_ONLY: loc.append_only ? 'true' : 'false',
            CUSTOM_RETENTION: loc.custom_retention ? 'true' : 'false',
            KEEP_LAST: loc.keep_last, KEEP_DAILY: loc.keep_daily,
            KEEP_WEEKLY: loc.keep_weekly, KEEP_MONTHLY: loc.keep_monthly,
            KEEP_YEARLY: loc.keep_yearly
        };

        // Field metadata comes from configs.json (window.configData) via
        // locFieldMeta; the basic/advanced split is decided by the caller, which
        // renders each group into its own tab (Connection vs Advanced).
        let html = '<div class="config-fields">';
        for (const suffix of suffixes) {
            const m = this.locFieldMeta(idx, suffix);
            if (!m.exists) continue;
            const value = (locValueLookup[suffix] ?? '').toString();
            html += ConfigShared.generateField(`config-${m.key}`, m.key, value, m.title, m.description, {}, {});
        }
        html += '</div>';
        return html;
    }

    /* Resolve a location field's metadata. Source of truth is configs.json
       (window.configData) — titles/descriptions/options + a per-field "advanced"
       flag; BACKUP_LOC_FIELD_DEFS is the fallback for sparse location.configs.
       LOC_ADVANCED_SUFFIXES keeps the known overrides advanced even on legacy
       locations whose config predates the **ADVANCED** marker. */
    locFieldMeta(idx, suffix) {
        const key = `CFG_BACKUP_LOC_${idx}_${suffix}`;
        const cfg = window.configData?.config?.[key] || {};
        const def = BACKUP_LOC_FIELD_DEFS[suffix] || {};
        return {
            key,
            exists: !!(cfg.title || cfg.description || BACKUP_LOC_FIELD_DEFS[suffix]),
            title: cfg.title || def.title || suffix,
            description: cfg.description ?? def.description ?? '',
            advanced: LOC_ADVANCED_SUFFIXES.has(suffix) || cfg.advanced === true
        };
    }

    /* Ordered field list for a location type. Primary source is the generator-
       emitted schema.json (this.locSchema); BACKUP_LOC_FIELDS_BY_TYPE is the
       fallback if that file didn't load. */
    locFieldsForType(type) {
        return this.locSchema?.types?.[type]
            || BACKUP_LOC_FIELDS_BY_TYPE[type]
            || BACKUP_LOC_FIELDS_BY_TYPE.local;
    }

    /* Split a type's fields into the Connection tab vs the Advanced tab. */
    locFieldGroups(idx, type) {
        const suffixes = this.locFieldsForType(type);
        const connection = [];
        const advanced = [];
        for (const suffix of suffixes) {
            const m = this.locFieldMeta(idx, suffix);
            if (!m.exists) continue;
            (m.advanced ? advanced : connection).push(suffix);
        }
        return { connection, advanced };
    }

    /* Connection-tab body: the generic fields plus, for sftp, the SSH key card.
       Used both on first render and when the Type select changes. */
    renderConnectionInner(idx, type, loc, connectionSuffixes) {
        let html = this.renderLocFields(idx, connectionSuffixes, loc);
        if (type === 'sftp') html += this.renderBackupSshKeyCard(loc);
        return html;
    }

    /* SSH key card for an sftp location. LibrePortal holds the private key in
       the location's ssh.key file; only the public key is shown — that's what
       you paste into the remote server's authorized_keys. Hidden by
       applySshAuthVisibility when SSH auth = password. */
    renderBackupSshKeyCard(l) {
        const idx = l.idx;
        const hasKey = l.ssh_key_exists === true;
        const pub = l.ssh_public_key || '';
        const body = hasKey ? `
            <p class="backup-card-hint">Add this public key to the remote server's <code>~/.ssh/authorized_keys</code>:</p>
            <textarea class="backup-ssh-pubkey" readonly rows="2" spellcheck="false">${this.escape(pub)}</textarea>
            <div class="backup-ssh-key-actions">
                <button type="button" class="backup-secondary-btn" data-action="ssh-key-copy" data-loc="${idx}">Copy public key</button>
                <button type="button" class="backup-danger-btn" data-action="ssh-key-delete" data-loc="${idx}">Delete key</button>
            </div>` : `
            <p class="backup-card-hint">Paste an existing private key, or generate one and we'll show the public key to add on the remote.</p>
            <textarea class="backup-ssh-keyinput" rows="4" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
            <div class="backup-ssh-key-actions">
                <button type="button" class="backup-primary-btn" data-action="ssh-key-save" data-loc="${idx}">Save key</button>
                <button type="button" class="backup-secondary-btn" data-action="ssh-key-generate" data-loc="${idx}">Generate keypair</button>
            </div>`;
        return `
            <div class="backup-ssh-key-card" data-loc="${idx}">
                <div class="backup-ssh-key-head">
                    <span class="backup-ssh-key-title">SSH key</span>
                    <span class="backup-ssh-key-status ${hasKey ? 'ok' : 'none'}">${hasKey ? '✓ Key configured' : 'No key yet'}</span>
                </div>
                ${body}
            </div>`;
    }

    async saveBackupSshKey(idx) {
        const card = document.querySelector(`.backup-ssh-key-card[data-loc="${idx}"]`);
        const key = (card?.querySelector('.backup-ssh-keyinput')?.value || '').trim();
        if (!key) { this.notify('Paste a private key first', 'error'); return; }
        const b64 = btoa(unescape(encodeURIComponent(key + '\n')));
        await this.runTask(`libreportal backup location ssh-key-set ${idx} ${b64}`, 'backup', null);
    }

    async generateBackupSshKey(idx) {
        await this.runTask(`libreportal backup location ssh-key-generate ${idx}`, 'backup', null);
    }

    async deleteBackupSshKey(idx) {
        if (!confirm("Delete this location's SSH key? Backups here will fail until a new key is set and added on the remote.")) return;
        await this.runTask(`libreportal backup location ssh-key-delete ${idx}`, 'backup', null);
    }

    async copyBackupSshKey(idx) {
        const loc = (this.locations?.locations || []).find(l => l.idx === idx);
        const pub = loc?.ssh_public_key || '';
        try { await navigator.clipboard.writeText(pub); this.notify('Public key copied', 'success'); }
        catch { this.notify('Copy failed — select the text and copy manually', 'error'); }
    }

    tagFieldsForSave(container) {
        container.querySelectorAll('input[name], select[name], textarea[name]').forEach(el => {
            if (!el.hasAttribute('data-backup-field')) {
                el.setAttribute('data-backup-field', '');
                if (el.type === 'checkbox') el.setAttribute('data-backup-bool', '');
            }
        });
    }

    async saveLocationModal() {
        const modal = document.getElementById('backup-location-modal');
        if (!modal) return;
        const idx = parseInt(modal.dataset.locIdx, 10);
        this.closeAllModals();
        await this.saveSection(`location-${idx}`);
    }

    async deleteLocationModal() {
        const modal = document.getElementById('backup-location-modal');
        if (!modal) return;
        const idx = parseInt(modal.dataset.locIdx, 10);
        const loc = (this.locations?.locations || []).find(l => l.idx === idx);
        const name = loc?.name || `Location ${idx}`;
        if (!confirm(`Delete location "${name}"?\n\nBackup data already stored at this location is not deleted by this action — only LibrePortal's reference to it. The password file on disk also stays in place (rename it manually if you want to start fresh).`)) return;
        this.closeAllModals();
        await this.runTask(`libreportal backup location remove ${idx}`, 'backup', null);
        setTimeout(() => this.reloadAfterSave(), 2000);
    }

    /* ----- Add location modal ----- */

    openAddLocationModal() {
        const modal = document.getElementById('backup-add-location-modal');
        const body = document.getElementById('backup-add-location-modal-body');
        if (!modal || !body) return;
        body.innerHTML = `
            <div class="backup-form-grid">
                ${this.formSelect('__add_type', 'Type', 'local', [
                    ['local',  'Local / mounted path'],
                    ['sftp',   'SFTP'],
                    ['rest',   'REST server'],
                    ['s3',     'S3'],
                    ['b2',     'Backblaze B2'],
                    ['gs',     'Google Cloud Storage'],
                    ['azure',  'Azure'],
                    ['rclone', 'rclone']
                ])}
                ${this.formInput('__add_name', 'Friendly name', '', 'text', 'e.g. Office NAS')}
            </div>
            <p class="backup-card-hint" style="margin-top:12px">The location starts disabled — fill in its connection details on the next screen, then toggle Enabled.</p>
        `;
        modal.classList.add('open');
    }

    async confirmAddLocation() {
        const modal = document.getElementById('backup-add-location-modal');
        if (!modal) return;
        const name = modal.querySelector('[name="__add_name"]')?.value?.trim();
        const type = modal.querySelector('[name="__add_type"]')?.value || 'local';
        if (!name) { this.notify('Name is required.', 'error'); return; }
        this.closeAllModals();
        const safeName = name.replace(/'/g, "'\\''");
        await this.runTask(`libreportal backup location add '${safeName}' ${type}`, 'backup', null);
        setTimeout(() => this.reloadAfterSave(), 2000);
    }

    /* ----- Snapshot restore/delete modals ----- */

    openRestoreModal(app, locIdx, snapshot) {
        const locName = this.locName(locIdx);
        const modal = document.getElementById('backup-restore-modal');
        const body = document.getElementById('backup-restore-modal-body');
        if (!modal || !body) return;
        body.innerHTML = `
            <p>Restore <strong>${this.escape(app)}</strong> from backup <code>${this.escape(snapshot)}</code> at <strong>${this.escape(locName)}</strong>?</p>
            <p class="backup-card-hint">The app will be stopped, its folder wiped, the snapshot restored in place, then the app started again. App-specific pre/post-restore hooks run if present.</p>
        `;
        modal.dataset.app = app;
        modal.dataset.locIdx = locIdx;
        modal.dataset.snapshot = snapshot;
        modal.classList.add('open');
    }

    openDeleteModal(app, locIdx, snapshot) {
        const locName = this.locName(locIdx);
        const modal = document.getElementById('backup-delete-modal');
        const body = document.getElementById('backup-delete-modal-body');
        if (!modal || !body) return;
        body.innerHTML = `
            <p>Delete backup <code>${this.escape(snapshot)}</code> for <strong>${this.escape(app)}</strong> from <strong>${this.escape(locName)}</strong>?</p>
            <p class="backup-card-hint">This cannot be undone. Append-only locations will reject the operation.</p>
        `;
        modal.dataset.app = app;
        modal.dataset.locIdx = locIdx;
        modal.dataset.snapshot = snapshot;
        modal.classList.add('open');
    }

    locName(idx) {
        const l = (this.locations?.locations || []).find(x => String(x.idx) === String(idx));
        return l?.name || `Location ${idx}`;
    }

    closeAllModals() {
        document.querySelectorAll('.backup-modal.open').forEach(m => m.classList.remove('open'));
    }

    async confirmRestore() {
        const modal = document.getElementById('backup-restore-modal');
        const { app, locIdx, snapshot } = modal.dataset;
        this.closeAllModals();
        await this.runTask(`libreportal restore app start ${app} ${snapshot} ${locIdx}`, 'restore', app);
    }

    async confirmDelete() {
        const modal = document.getElementById('backup-delete-modal');
        const { app, locIdx, snapshot } = modal.dataset;
        this.closeAllModals();
        await this.runTask(`libreportal backup app delete ${app} ${locIdx}:${snapshot}`, 'backup', app);
    }

    async runBackupAllApps() {
        await this.runTask(`libreportal backup all`, 'backup', null);
    }

    async runBackupSystem() {
        await this.runTask(`libreportal backup system`, 'backup', null);
    }

    async confirmRestoreSystem() {
        if (!confirm('Restore the latest system-config snapshot?\n\nIt is restored into a staging folder (not applied) — review it, then copy what you need (e.g. backup-location credentials, login, settings) into the live config. Your running config is NOT overwritten.')) return;
        await this.runTask(`libreportal restore system`, 'restore', null);
    }

    /* ----- Migrate (Phase 1: shared-backup) ----- */

    renderMigrate() {
        const body  = document.getElementById('backup-migrate-body');
        const empty = document.getElementById('backup-migrate-empty');
        if (!body || !empty) return;

        const data = this.migrate || {};
        const locations = (data.locations || []).filter(l => (l.hosts || []).length > 0);

        if (!locations.length) {
            body.innerHTML = '';
            empty.hidden = false;
            return;
        }
        empty.hidden = true;

        // Group: one card per source-host, with that host's apps listed underneath.
        // We collapse across locations — if the same host appears in two locations,
        // we still show it once with the union of apps (the per-app row carries
        // which location it came from). Most setups have one shared location anyway.
        const installed = new Set(data.destination?.installed_apps || []);
        const html = locations.map(loc => `
            <div class="backup-migrate-location">
                <div class="backup-card-header" style="margin-bottom:8px">
                    <h3 style="margin:0">${this.escape(loc.name || 'Location')}</h3>
                    <span class="backup-card-hint">${(loc.hosts || []).length} other host${loc.hosts.length === 1 ? '' : 's'} backing up here</span>
                </div>
                ${loc.hosts.map(host => `
                    <div class="backup-migrate-host" style="border:1px solid var(--border-color, #2a2a2a); border-radius:8px; padding:14px; margin-bottom:12px">
                        <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:10px">
                            <div>
                                <strong style="font-size:1.05em">${this.escape(host.hostname)}</strong>
                                <span class="backup-card-hint" style="margin-left:10px">${(host.apps || []).length} app${host.apps.length === 1 ? '' : 's'} available</span>
                            </div>
                            <button class="backup-primary-btn" data-action="migrate-host"
                                data-loc="${loc.idx}" data-host="${this.escape(host.hostname)}">
                                Migrate every app from this host
                            </button>
                        </div>
                        <div class="backup-migrate-apps" style="display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:8px">
                            ${(host.apps || []).map(app => {
                                const collide = installed.has(app.slug);
                                return `
                                <div class="backup-migrate-app" style="display:flex; justify-content:space-between; align-items:center; padding:8px 12px; background:var(--surface-2, #1a1a1a); border-radius:6px">
                                    <div style="display:flex; flex-direction:column; min-width:0">
                                        <span style="display:flex; align-items:center; gap:8px">
                                            <strong>${this.escape(app.slug)}</strong>
                                            ${collide ? `<span class="backup-status-dot warn" title="Already installed here"></span>` : ''}
                                        </span>
                                        <span class="backup-card-hint" style="font-size:.82em">
                                            ${app.snapshots} snapshot${app.snapshots === 1 ? '' : 's'} · latest ${this.escape(this.formatRelativeTime(app.latest_date))}
                                        </span>
                                    </div>
                                    <button class="backup-secondary-btn" data-action="migrate-app"
                                        data-loc="${loc.idx}" data-host="${this.escape(host.hostname)}" data-app="${this.escape(app.slug)}">
                                        Migrate
                                    </button>
                                </div>
                                `;
                            }).join('')}
                        </div>
                    </div>
                `).join('')}
            </div>
        `).join('');

        body.innerHTML = html;
    }

    formatRelativeTime(iso) {
        if (!iso) return 'never';
        const t = Date.parse(iso);
        if (!t) return iso;
        const diff = Date.now() - t;
        const minute = 60_000, hour = 60 * minute, day = 24 * hour;
        if (diff < hour)    return `${Math.max(1, Math.round(diff / minute))} min ago`;
        if (diff < day)     return `${Math.round(diff / hour)} h ago`;
        if (diff < 7 * day) return `${Math.round(diff / day)} d ago`;
        return new Date(t).toISOString().slice(0, 10);
    }

    openMigrateModal({ mode, locIdx, host, app }) {
        const modal = document.getElementById('backup-migrate-modal');
        const body  = document.getElementById('backup-migrate-modal-body');
        if (!modal || !body) return;

        const dest = this.migrate?.destination || {};
        const installed = new Set(dest.installed_apps || []);
        const running   = new Set(dest.running_apps   || []);
        const locName = this.locName(locIdx);

        // App-mode: one specific app. Host-mode: every app from the host.
        let targetApps = [];
        if (mode === 'app') {
            targetApps = [app];
        } else {
            const loc = (this.migrate?.locations || []).find(l => l.idx === locIdx);
            const h = (loc?.hosts || []).find(x => x.hostname === host);
            targetApps = (h?.apps || []).map(a => a.slug);
        }
        const collisions = targetApps.filter(a => installed.has(a));
        const collisionsRunning = collisions.filter(a => running.has(a));

        const intro = mode === 'app'
            ? `<p>Migrate <strong>${this.escape(app)}</strong> from <strong>${this.escape(host)}</strong> via <strong>${this.escape(locName)}</strong> onto this host.</p>`
            : `<p>Migrate <strong>every app</strong> (${targetApps.length}) from <strong>${this.escape(host)}</strong> via <strong>${this.escape(locName)}</strong> onto this host.</p>`;

        let collisionNote = '';
        if (collisions.length) {
            collisionNote = `
                <p class="backup-card-hint" style="color:var(--warning, #d97706); margin-top:8px">
                    ⚠ Already installed here: ${collisions.map(c => `<code>${this.escape(c)}</code>`).join(', ')}.
                    These will be <strong>replaced</strong>.
                    ${collisionsRunning.length ? `Currently running: ${collisionsRunning.map(c => `<code>${this.escape(c)}</code>`).join(', ')} — will be stopped first.` : ''}
                </p>
            `;
        }

        body.innerHTML = `
            ${intro}
            ${collisionNote}
            <div style="margin-top:14px; display:flex; flex-direction:column; gap:8px">
                <label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
                    <input type="checkbox" id="migrate-opt-pre-backup" ${collisions.length ? 'checked' : 'disabled'}>
                    <span>
                        Back up the destination's existing copy first
                        <span class="backup-card-hint" style="display:block; font-size:.85em">
                            Safety net: snapshot the current ${mode === 'app' ? this.escape(app) : 'app'} into your first
                            enabled backup location (tagged <code>pre-migrate</code>) before wipe.
                            ${collisions.length ? '' : 'No collision — nothing to back up.'}
                        </span>
                    </span>
                </label>
                <label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
                    <input type="checkbox" id="migrate-opt-rewrite-urls" checked>
                    <span>
                        Rewrite host-bound URLs to this host
                        <span class="backup-card-hint" style="display:block; font-size:.85em">
                            Replaces <code>CFG_*_URL</code>, <code>*_DOMAIN</code>, <code>*_HOSTNAME</code> with this
                            host's values. Uncheck only if you want the moved app to keep claiming the source's hostname.
                        </span>
                    </span>
                </label>
            </div>
        `;
        modal.dataset.mode   = mode;
        modal.dataset.locIdx = String(locIdx);
        modal.dataset.host   = host;
        modal.dataset.app    = app || '';
        modal.classList.add('open');
    }

    async confirmMigrate() {
        const modal = document.getElementById('backup-migrate-modal');
        if (!modal) return;
        const { mode, locIdx, host, app } = modal.dataset;
        const preBackup = document.getElementById('migrate-opt-pre-backup')?.checked;
        const rewrite   = document.getElementById('migrate-opt-rewrite-urls')?.checked;

        // The bash CLI defaults are: pre-backup ON, URL rewrite ON. Opt-out flags
        // only get appended when the user un-ticks; matches the kernel's defaults.
        const opts = [];
        if (preBackup === false) opts.push('--no-pre-backup');
        if (rewrite   === false) opts.push('--keep-urls');
        const optStr = opts.length ? ' ' + opts.join(' ') : '';

        this.closeAllModals();
        if (mode === 'app') {
            await this.runTask(
                `libreportal restore migrate app ${app} ${host} ${locIdx}${optStr}`,
                'restore', app);
        } else {
            await this.runTask(
                `libreportal restore migrate system ${host} ${locIdx}${optStr}`,
                'restore', null);
        }
    }

    async runTask(command, type, app) {
        if (!this.taskManager) {
            this.notify('Task system unavailable', 'error');
            return;
        }
        try {
            await this.taskManager.createTask(command, type, app);
            setTimeout(() => this.refreshAll().then(() => this.render()), 1500);
        } catch (err) {
            this.notify(`Failed to queue task: ${err.message || err}`, 'error');
        }
    }

    /* ----- Generic save handler ----- */

    async setLocationEnabled(idx, enabled) {
        const encoded = `CFG_BACKUP_LOC_${idx}_ENABLED=${enabled ? 'true' : 'false'}`;
        try {
            if (!window.tasksManager?.router) throw new Error('Task system not available');
            await window.tasksManager.router.routeAction('config_update', {
                changes: `'${encoded.replace(/'/g, "'\\''")}'`
            });
            this.notify(`${enabled ? 'Enabling' : 'Disabling'} this location…`, 'success');
            setTimeout(() => this.reloadAfterSave(), 2500);
        } catch (err) {
            this.notify(`Save failed: ${err.message || err}`, 'error');
        }
    }

    async saveSection(sectionId) {
        let scope;
        if (sectionId.startsWith('location-')) {
            const idx = sectionId.slice('location-'.length);
            scope = document.querySelector(`.backup-location-row[data-loc="${idx}"] .task-details`);
        } else {
            scope = document.querySelector(`#backup-panel-${sectionId}`);
        }
        if (!scope) return;

        const cfg = window.systemConfigs || {};
        const changes = [];
        scope.querySelectorAll('[data-backup-field]').forEach(el => {
            const name = el.name;
            if (!name || name.startsWith('__')) return;
            let value;
            if (el.hasAttribute('data-backup-bool')) {
                value = el.checked ? 'true' : 'false';
            } else {
                value = (el.value ?? '').toString();
            }
            const original = (cfg[name] ?? '').toString();
            if (value === original) return;
            changes.push(`${name}=${value.replace(/\|/g, '%7C')}`);
        });

        if (!changes.length) {
            this.notify('No changes to save.', 'info');
            return;
        }

        const encoded = changes.join('|');
        try {
            if (!window.tasksManager?.router) throw new Error('Task system not available');
            await window.tasksManager.router.routeAction('config_update', {
                changes: `'${encoded.replace(/'/g, "'\\''")}'`
            });
            this.notify(`Saving ${changes.length} change${changes.length === 1 ? '' : 's'}…`, 'success');
            setTimeout(() => this.reloadAfterSave(), 2500);
        } catch (err) {
            this.notify(`Save failed: ${err.message || err}`, 'error');
        }
    }

    async reloadAfterSave() {
        await this.refreshAll();
        this.render();
    }

    notify(message, type) {
        if (window.notificationSystem) {
            window.notificationSystem.show(message, type || 'info');
        } else {
            console.log(`[backup ${type || 'info'}] ${message}`);
        }
    }

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

    formatBytes(b) {
        if (!b || b < 0) return '0 B';
        const units = ['B', 'KB', 'MB', 'GB', 'TB'];
        let i = 0;
        let v = b;
        while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
        return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`;
    }

    formatRelative(iso) {
        if (!iso) return '—';
        const t = new Date(iso).getTime();
        if (!t) return iso;
        const diff = Math.max(0, Date.now() - t);
        const s = Math.floor(diff / 1000);
        if (s < 60)  return 'just now';
        const m = Math.floor(s / 60);
        if (m < 60)  return `${m}m ago`;
        const h = Math.floor(m / 60);
        if (h < 48)  return `${h}h ago`;
        const d = Math.floor(h / 24);
        if (d < 30)  return `${d}d ago`;
        return new Date(iso).toLocaleDateString();
    }
}

window.BackupPage = BackupPage;
