// Admin → System → Storage — where the disk is going.
//
// Mounted at /admin/system/storage. Two data sources:
//
//   - Storage by app — on-disk size of each app's bind-mounted data, from the
//     generated /data/system/app_storage.json (du, not docker — see the
//     generator). The headline view for LibrePortal, whose data lives in bind
//     mounts, not named volumes.
//   - Docker engine `docker system df` — headline total + reclaimable, a
//     donut (images / build cache), per-category cards, and a Tasks-style list
//     of every image (removable individually or in bulk). The daemon's own
//     overhead, separate from the per-app data.
//
// One backend call (GET /api/system/storage), cached server-side for 5s.
// Mutations go through the task system, never a direct API: "Reclaim space"
// runs the safe prune via the system_reclaim task; image removal runs
// `libreportal system image rm` via the system_image_rm task. Both re-read
// usage when the task lands.

class SystemStoragePage {
    constructor(rootId = 'config-section') {
        this.rootId = rootId;
        this.data = null;
        this._timer = null;
        this._expanded = new Set();   // app names whose folder breakdown is open
        this._selectedImageIds = new Set();   // images ticked for bulk delete
        this._imageFilter = 'all';     // images list filter: all | inuse | unused
        this._deleting = false;        // a remove-images task is in flight
        this._onClick = this._onClick.bind(this);
        this._onChange = this._onChange.bind(this);
    }

    root() { return document.getElementById(this.rootId); }

    async mount() {
        this._renderShell();
        await this._load();
        this._render();
        const r = this.root();
        if (r) { r.addEventListener('click', this._onClick); r.addEventListener('change', this._onChange); }
        // Refresh every 30s while mounted. Skipped while a delete task is in
        // flight so it doesn't fight the post-completion refresh or re-enable
        // buttons mid-operation.
        this._timer = setInterval(() => {
            if (!document.querySelector('.sys-storage-page')) {
                clearInterval(this._timer); this._timer = null;
                return;
            }
            if (this._deleting) return;
            this._load().then(() => this._render());
        }, 30000);
    }

    dispose() {
        if (this._timer) { clearInterval(this._timer); this._timer = null; }
        const r = this.root();
        if (r) { r.removeEventListener('click', this._onClick); r.removeEventListener('change', this._onChange); }
    }

    _onClick(e) {
        const back = e.target.closest('[data-back]');
        if (back && window.navigateToRoute) {
            window.navigateToRoute('/admin/system');
            return;
        }
        const recl = e.target.closest('[data-storage-reclaim]');
        if (recl) { this._reclaim(recl); return; }
        // Remove a single image.
        const del = e.target.closest('[data-image-delete]');
        if (del) { this._deleteImages([del.getAttribute('data-image-delete')]); return; }
        // Filter chip (All / In use / Unused) — re-render the list in place,
        // dropping any selection that belongs to the now-hidden group.
        const filt = e.target.closest('[data-image-filter]');
        if (filt) {
            const f = filt.getAttribute('data-image-filter');
            if (f && f !== this._imageFilter) {
                this._imageFilter = f;
                this._selectedImageIds.clear();
                this._rerenderImages();
            }
            return;
        }
        // Clear All / Delete Selected. With no explicit selection, act on the
        // rows currently shown (i.e. honour the active filter).
        const clr = e.target.closest('[data-images-clear]');
        if (clr) {
            const ids = this._selectedImageIds.size
                ? [...this._selectedImageIds]
                : this._visibleImages().map(im => im.id);
            this._deleteImages(ids);
            return;
        }
        // Expand/collapse an app's per-folder breakdown. Toggle the DOM directly
        // (no re-render) and remember the state so the 30s refresh re-applies it.
        const tog = e.target.closest('[data-app-toggle]');
        if (tog) {
            const app = tog.getAttribute('data-app-toggle');
            if (this._expanded.has(app)) this._expanded.delete(app);
            else this._expanded.add(app);
            const open = this._expanded.has(app);
            const detail = this.root()?.querySelector(`[data-app-detail="${CSS.escape(app)}"]`);
            if (detail) detail.classList.toggle('is-collapsed', !open);
            tog.querySelector('.sys-storage-caret')?.classList.toggle('is-open', open);
        }
    }

    _onChange(e) {
        const box = e.target.closest('[data-image-select]');
        if (box) { this.toggleImageSelection(box.getAttribute('data-image-select'), box.checked); return; }
        const all = e.target.closest('[data-images-select-all]');
        if (all) { this.toggleSelectAllImages(all.checked); return; }
    }

    // Confirm, then run the safe reclaim task and re-read usage as it lands.
    // The button lives in the header (not the body), so it survives the
    // _render() refreshes below — re-enable it explicitly when done.
    _reclaim(btn) {
        const done = () => { btn.disabled = false; btn.classList.remove('is-running'); };
        const run = async () => {
            btn.disabled = true;
            btn.classList.add('is-running');
            try {
                if (!window.tasksManager?.router?.routeAction) {
                    throw new Error('Task system not ready');
                }
                // routeAction → executeTask already shows the rich "Reclaim
                // Space task started!" toast (and a completion toast lands when
                // it finishes), so no extra info notification here.
                await window.tasksManager.router.routeAction('system_reclaim');
                // The prune runs in the background task processor; re-read
                // usage a couple of times so the numbers settle without a
                // manual refresh.
                setTimeout(() => this._load().then(() => this._render()), 3000);
                setTimeout(() => { this._load().then(() => this._render()); done(); }, 8000);
            } catch (err) {
                window.notificationSystem?.show?.(`Reclaim failed: ${err.message || err}`, 'error');
                done();
            }
        };
        if (window.showConfirmation) {
            window.showConfirmation(
                'Reclaim space',
                'Clear the build cache and dangling images? Images in use and your app data are left untouched.',
                run,
                'Reclaim space',
                'Cancel',
                'warning'
            );
        } else {
            run();
        }
    }

    async _load() {
        // Live docker df + the periodically-generated per-app on-disk sizes.
        // The latter is a static generator artifact (du is too heavy for a live
        // call), so a missing/empty file just means "not measured yet".
        const [storage, app] = await Promise.all([
            fetch('/api/system/storage').then(r => r.json()).catch(() => null),
            fetch(`/data/system/app_storage.json?t=${Date.now()}`).then(r => r.ok ? r.json() : null).catch(() => null),
        ]);
        this.data = storage;
        this.appStorage = app;
    }

    _renderShell() {
        const r = this.root();
        if (!r) return;
        r.innerHTML = `
            <div class="admin-page sys-storage-page">
                <div class="page-header config-page-header">
                    <div class="page-header-title">
                        <div class="admin-breadcrumb">
                            <a href="/admin/system" data-back>Admin · System</a>
                        </div>
                        <h1>Storage</h1>
                        <p class="sys-storage-sub">On-disk space by app, plus Docker's own engine usage.</p>
                    </div>
                    <button type="button" class="sys-storage-reclaim" data-storage-reclaim title="Clear build cache and dangling images (images in use and app data are kept)">
                        <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
                        Reclaim space
                    </button>
                </div>
                <div class="sys-storage-body" data-storage-body>
                    <div class="sys-storage-loading">Reading Docker daemon…</div>
                </div>
            </div>`;
    }

    // Docker engine categories. Warm colours = reclaimable overhead: Images get
    // the same orange as the Reclaim action, build cache the deeper red.
    static segmentsFrom(d) {
        return [
            { key: 'images',      label: 'Images',      color: 'status-warning', data: (d && d.images) || {} },
            { key: 'build_cache', label: 'Build cache', color: 'status-danger',  data: (d && d.build_cache) || {} },
        ];
    }

    // Cool colours for apps (your data), so they read as distinct from the warm
    // Docker engine slices.
    static get APP_PALETTE() { return ['accent', 'status-info', 'status-success']; }

    // The full LibrePortal storage breakdown: a slice per app followed by the
    // Docker engine categories. Used on this page's donut, where the per-app
    // detail is the point.
    static unifiedSegments(appStorage, dockerData) {
        const pal = SystemStoragePage.APP_PALETTE;
        const apps = (appStorage && Array.isArray(appStorage.apps)) ? appStorage.apps : [];
        const appSegs = apps.map((a, i) => ({ key: 'app:' + a.app, label: a.app, color: pal[i % pal.length], data: { size: a.bytes || 0 }, kind: 'app' }));
        const docker = dockerData ? SystemStoragePage.segmentsFrom(dockerData).map(s => ({ ...s, kind: 'docker' })) : [];
        return [...appSegs, ...docker];
    }

    // Summary breakdown for the System-page overview: all apps collapse into one
    // "Applications" slice (their total), then the Docker engine categories. The
    // per-app split lives on the full Storage page, not here.
    static summarySegments(appStorage, dockerData) {
        const appsTotal = (appStorage && Number(appStorage.total)) || 0;
        const seg = appsTotal > 0
            ? [{ key: 'apps', label: 'Applications', color: 'accent', data: { size: appsTotal }, kind: 'app' }]
            : [];
        const docker = dockerData ? SystemStoragePage.segmentsFrom(dockerData).map(s => ({ ...s, kind: 'docker' })) : [];
        return [...seg, ...docker];
    }

    // Hand-rolled donut, full circle = total. Each slice's dashoffset is the
    // running cumulative fraction (off), so a slice starts where the previous
    // one ended and the ring fills proportionally.
    static donutSvg(segments, total, sub = 'total in use') {
        const fmt = window.SystemFmt;
        const denom = total || 1;
        const r0 = 90, stroke = 28, C = 2 * Math.PI * r0;
        let acc = 0;
        const slices = segments.map(s => {
            const v = (s.data && s.data.size) || 0;
            const off = C * (1 - acc);
            acc += v / denom;
            return { color: s.color, len: C * (v / denom), off };
        });
        return `
            <svg viewBox="0 0 240 240" class="sys-storage-donut" role="img" aria-label="Storage breakdown">
                <g transform="translate(120 120) rotate(-90)">
                    <circle cx="0" cy="0" r="${r0}" fill="none" stroke="rgba(var(--text-rgb), 0.08)" stroke-width="${stroke}"/>
                    ${slices.map(s => s.len > 0
                        ? `<circle cx="0" cy="0" r="${r0}" fill="none"
                                  stroke="var(--${s.color})" stroke-width="${stroke}"
                                  stroke-dasharray="${s.len.toFixed(1)} ${(C - s.len).toFixed(1)}"
                                  stroke-dashoffset="${s.off.toFixed(1)}"
                                  style="transition:stroke-dasharray .4s ease"/>`
                        : ''
                    ).join('')}
                </g>
                <text x="120" y="115" text-anchor="middle" class="sys-storage-donut-total">${fmt.bytes(total || 0)}</text>
                <text x="120" y="138" text-anchor="middle" class="sys-storage-donut-sub">${fmt.escape(sub)}</text>
            </svg>`;
    }

    _render() {
        const r = this.root();
        if (!r) return;
        const body = r.querySelector('[data-storage-body]');
        if (!body) return;
        const fmt = window.SystemFmt;
        const d = this.data;                       // Docker `system df` (secondary)
        const as = this.appStorage;                // per-app on-disk usage (primary)
        const appRows = (as && Array.isArray(as.apps)) ? as.apps : [];
        if (!d && !appRows.length) {
            body.innerHTML = `<div class="sys-storage-err">Couldn't read storage usage.</div>`;
            return;
        }
        const APP_PAL = SystemStoragePage.APP_PALETTE;
        const appColorFor = i => APP_PAL[i % APP_PAL.length];

        // ---- Headline: ONE donut covering everything — a slice per app, then
        // Docker's own categories (images, build cache). The legend lists them
        // all. This is the unified "where's my disk going" view.
        const appTotal = (as && as.total) || 0;
        const allSegs = SystemStoragePage.unifiedSegments(as, d);
        const grandTotal = allSegs.reduce((t, s) => t + ((s.data && s.data.size) || 0), 0);
        const legend = `
            <ul class="sys-storage-legend">
                ${allSegs.map(s => `
                    <li>
                        <span class="sys-storage-swatch" style="background: var(--${s.color})"></span>
                        <span class="sys-storage-leg-k">${fmt.escape(s.label)}</span>
                        <span></span>
                        <span class="sys-storage-leg-v">${fmt.bytes((s.data && s.data.size) || 0)}</span>
                    </li>`).join('')}
            </ul>`;
        const headline = allSegs.length
            ? `<div class="sys-storage-headline">
                    <div class="sys-storage-head-card">
                        ${SystemStoragePage.donutSvg(allSegs, grandTotal, 'in use')}
                        ${legend}
                    </div>
                    <div class="sys-storage-head-stats">
                        <div class="sys-storage-stat">
                            <span class="sys-storage-stat-k">App data on disk</span>
                            <strong class="sys-storage-stat-v">${fmt.bytes(appTotal)}</strong>
                            <span class="sys-storage-stat-sub">${appRows.length} app${appRows.length === 1 ? '' : 's'}${as && as.total_external ? ` · ${fmt.bytes(as.total_external)} external` : ''}</span>
                        </div>
                        ${d ? `<div class="sys-storage-stat sys-storage-stat-recl">
                            <span class="sys-storage-stat-k">Docker reclaimable</span>
                            <strong class="sys-storage-stat-v">${fmt.bytes(d.reclaimable || 0)}</strong>
                            <span class="sys-storage-stat-sub">of ${fmt.bytes(d.total || 0)} engine overhead</span>
                        </div>` : ''}
                    </div>
                </div>`
            : `<div class="sys-storage-app-empty">${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}</div>`;

        // ---- Storage by app — same Tasks-style list as Images (no selection).
        // A coloured dot keys the row to its donut slice; the app icon + name
        // identify it; clicking a row expands its per-folder breakdown.
        const appsSection = appRows.length ? `
            <div class="sys-section-head"><h2>Storage by app</h2><span class="sys-chart-meta">on-disk data per app — click an app to see its folders</span></div>
            <div class="sys-tasklist sys-storage-app-list">
                ${appRows.map((a, i) => {
                    const mounts = Array.isArray(a.mounts) ? a.mounts : [];
                    const open = this._expanded.has(a.app);
                    const folders = mounts.map(m => `
                        <li>
                            <span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span>
                            ${m.external ? '<span class="sys-storage-ext-badge">external</span>' : ''}
                            <span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span>
                        </li>`).join('');
                    return `
                    <div class="task-item sys-storage-app-item${mounts.length ? ' is-clickable' : ''}"${mounts.length ? ` data-app-toggle="${fmt.escape(a.app)}"` : ''}>
                        <div class="task-header">
                            <div class="task-info">
                                <span class="sys-storage-swatch" style="background: var(--${appColorFor(i)})"></span>
                                ${this._appIconHtml(a.app)}
                                <span class="task-title">${fmt.escape(a.app)}</span>
                                <span class="task-time">${fmt.bytes(a.bytes || 0)}</span>
                                ${a.external_bytes ? `<span class="task-duration" title="on a separate disk">${fmt.bytes(a.external_bytes)} external</span>` : ''}
                            </div>
                            <div class="task-actions">
                                ${mounts.length ? `<span class="sys-storage-caret${open ? ' is-open' : ''}">▸</span>` : ''}
                            </div>
                        </div>
                        ${mounts.length ? `<ul class="sys-storage-folders sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">${folders}</ul>` : ''}
                    </div>`;
                }).join('')}
            </div>` : '';

        // ---- Docker engine (secondary): images + build cache the daemon keeps,
        // plus the largest images. The cleanup view, clearly separate from data.
        let dockerSection = '';
        if (d) {
            const total = d.total || 0;
            const recl = d.reclaimable || 0;
            const cards = SystemStoragePage.segmentsFrom(d).map(s => {
                const v = s.data || {};
                const pct = v.size && total ? (v.size / total) * 100 : 0;
                const rPct = v.size ? (v.reclaimable / v.size) * 100 : 0;
                return `
                    <div class="sys-storage-card" style="--cat: var(--${s.color}); --cat-rgb: var(--${s.color}-rgb)">
                        <div class="sys-storage-card-head">
                            <h3>${s.label}</h3>
                            <span class="sys-storage-card-count">${v.count ?? 0}</span>
                        </div>
                        <div class="sys-storage-card-size">${fmt.bytes(v.size || 0)}</div>
                        <div class="sys-storage-card-bar"><span style="width:${pct.toFixed(1)}%"></span></div>
                        <div class="sys-storage-card-meta">
                            <span>${pct.toFixed(1)}% of engine</span>
                            ${v.reclaimable ? `<span class="sys-storage-card-recl">${fmt.bytes(v.reclaimable)} reclaimable (${rPct.toFixed(0)}%)</span>` : ''}
                        </div>
                    </div>`;
            }).join('');
            const images = Array.isArray(d.image_list) ? d.image_list : [];
            dockerSection = `
                <div class="sys-section-head"><h2>Docker engine</h2><span class="sys-chart-meta">${fmt.bytes(total)} · ${fmt.bytes(recl)} reclaimable — the daemon's own usage, separate from your app data</span></div>
                <div class="sys-storage-cards">${cards}</div>
                <div id="sys-images-region">${this._renderImages(images)}</div>`;
        }

        body.innerHTML = `${headline}${appsSection}${dockerSection}`;
        // Re-apply selection state (button label + master checkbox) after the
        // innerHTML rebuild so it survives the 30s refresh.
        this._updateImageSelectionUI();
    }

    // ---- Images list (Tasks-style: select, bulk-delete) ---------------------

    // Friendly image label: first repo tag, else a short id. <none> tags mean a
    // dangling layer — show the short id instead.
    _imageName(im) {
        const tag = (im.repo_tags || []).find(t => t && !t.includes('<none>'));
        if (tag) return tag;
        const id = (im.id || '').replace(/^sha256:/, '');
        return id ? id.slice(0, 12) : '—';
    }

    // {cls, label} for the status pill: in use / unused / dangling. Never red —
    // an unused or dangling image isn't "wrong", just reclaimable.
    _imagePill(im) {
        const dangling = !(im.repo_tags || []).some(t => t && !t.includes('<none>'));
        if (im.containers > 0) return { cls: 'is-inuse', label: `in use · ${im.containers}` };
        if (dangling) return { cls: 'is-dangling', label: 'dangling' };
        return { cls: 'is-unused', label: 'unused' };
    }

    // App-icon <img>, falling back to the generic app icon when the slug has no
    // bundled icon. Icons are served at /core/icons/apps/<slug>.svg.
    _iconImg(slug) {
        return `<img class="sys-task-icon" src="/core/icons/apps/${encodeURIComponent(slug)}.svg" alt="" onerror="this.onerror=null;this.src='/core/icons/apps/default.svg'">`;
    }

    _appIconHtml(app) {
        return this._iconImg(String(app || '').toLowerCase());
    }

    // The app an image belongs to, derived from its repo tag (registry +
    // namespace stripped, tag/digest dropped). null for dangling/untagged layers.
    _imageAppSlug(im) {
        const tag = (im.repo_tags || []).find(t => t && !t.includes('<none>'));
        if (!tag) return null;
        const repo = tag.split('@')[0].split(':')[0];
        const seg = (repo.split('/').pop() || '').toLowerCase();
        return seg || null;
    }

    // App icon for an image, or the generic "picture" glyph for untagged layers.
    _imageIconHtml(im) {
        const slug = this._imageAppSlug(im);
        if (slug) return this._iconImg(slug);
        return `<svg class="sys-img-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`;
    }

    // True when an image is backing at least one container.
    _imageInUse(im) { return (im && im.containers > 0); }

    // In-use images float to the top. Array#sort is stable, so within each
    // group the backend's largest-first order is preserved.
    _sortImages(images) {
        return images.slice().sort((a, b) =>
            (this._imageInUse(a) ? 0 : 1) - (this._imageInUse(b) ? 0 : 1));
    }

    // Does an image pass the active filter chip? "unused" means "not in use" —
    // tagged-but-idle images and dangling <none> layers alike.
    _imageMatchesFilter(im) {
        if (this._imageFilter === 'inuse') return this._imageInUse(im);
        if (this._imageFilter === 'unused') return !this._imageInUse(im);
        return true;
    }

    // The rows actually on screen (sorted + filtered) — what Select all and a
    // selection-less Clear All operate on.
    _visibleImages() {
        const all = Array.isArray(this.data && this.data.image_list) ? this.data.image_list : [];
        return this._sortImages(all).filter(im => this._imageMatchesFilter(im));
    }

    // Re-render just the images region in place (on a filter change), leaving
    // the donut/cards above untouched.
    _rerenderImages() {
        const region = this.root()?.querySelector('#sys-images-region');
        if (!region) { this._render(); return; }
        const images = Array.isArray(this.data && this.data.image_list) ? this.data.image_list : [];
        region.innerHTML = this._renderImages(images);
        this._updateImageSelectionUI();
    }

    _renderImages(images) {
        const fmt = window.SystemFmt;
        const trash = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><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"/></svg>`;

        if (!images.length) {
            return `<div class="sys-section-head"><h2>Images</h2></div><div class="sys-storage-app-empty">No images on disk.</div>`;
        }

        // Filter chips with a live count each; "all" leads with in-use images.
        const inUse = images.reduce((n, im) => n + (this._imageInUse(im) ? 1 : 0), 0);
        const counts = { all: images.length, inuse: inUse, unused: images.length - inUse };
        const chips = [
            { key: 'all', label: 'All' },
            { key: 'inuse', label: 'In use' },
            { key: 'unused', label: 'Unused' },
        ].map(c => {
            const on = this._imageFilter === c.key;
            return `<button type="button" class="sys-img-filter-btn${on ? ' is-active' : ''}" data-image-filter="${c.key}" aria-pressed="${on}">${c.label}<span class="sys-img-filter-n">${counts[c.key]}</span></button>`;
        }).join('');

        const visible = this._sortImages(images).filter(im => this._imageMatchesFilter(im));

        // Clear All / Select all act on the visible rows, so they only make
        // sense when the active filter actually has some.
        const actions = visible.length ? `
                    <div class="sys-images-actions">
                        <button type="button" class="clear-btn" data-images-clear id="sys-images-clear" title="Remove the images shown below">
                            ${trash}<span class="clear-btn-label">Clear All</span>
                        </button>
                        <label class="task-select-all" title="Select all images shown">
                            <input type="checkbox" data-images-select-all id="sys-images-select-all">
                            <span class="task-select-box" aria-hidden="true"></span>
                            <span class="task-select-all-label">Select all</span>
                        </label>
                    </div>` : '';

        const head = `
            <div class="sys-section-head sys-images-head">
                <h2>Images</h2>
                <div class="sys-images-toolbar">
                    <div class="sys-img-filter" role="group" aria-label="Filter images">${chips}</div>
                    ${actions}
                </div>
            </div>`;

        if (!visible.length) {
            const what = this._imageFilter === 'inuse' ? 'in-use' : 'unused';
            return `${head}<div class="sys-storage-app-empty">No ${what} images.</div>`;
        }

        const rows = visible.map(im => {
            const id = fmt.escape(im.id || '');
            const pill = this._imagePill(im);
            const selected = this._selectedImageIds.has(im.id);
            return `
            <div class="task-item sys-image-item" data-image-id="${id}">
                <div class="task-header">
                    <div class="task-info">
                        ${this._imageIconHtml(im)}
                        <div class="sys-img-body">
                            <span class="sys-img-headline">
                                <span class="task-title" title="${fmt.escape((im.repo_tags || []).join(', '))}">${fmt.escape(this._imageName(im))}</span>
                                <span class="task-status sys-img-pill ${pill.cls}">${fmt.escape(pill.label)}</span>
                            </span>
                            <span class="sys-img-meta">
                                <span class="task-time">${fmt.bytes(im.size || 0)}</span>
                                ${im.shared_size ? `<span class="task-duration" title="shared layers">${fmt.bytes(im.shared_size)} shared</span>` : ''}
                                ${im.created ? `<span class="task-duration">${fmt.timeAgo(im.created)}</span>` : ''}
                            </span>
                        </div>
                    </div>
                    <div class="task-actions">
                        <button type="button" class="task-btn delete" data-image-delete="${id}" title="Remove image">
                            ${trash}<span class="task-btn-label">Delete</span>
                        </button>
                        <label class="task-select" title="Select for bulk delete">
                            <input type="checkbox" data-image-select="${id}" ${selected ? 'checked' : ''}>
                            <span class="task-select-box" aria-hidden="true"></span>
                        </label>
                    </div>
                </div>
            </div>`;
        }).join('');

        return `${head}<div class="sys-tasklist sys-images-list" id="sys-images-list">${rows}</div>`;
    }

    // ---- selection (mirrors TasksManager) -----------------------------------
    toggleImageSelection(id, checked) {
        if (checked) this._selectedImageIds.add(id);
        else this._selectedImageIds.delete(id);
        this._updateImageSelectionUI();
    }

    toggleSelectAllImages(checked) {
        const boxes = this.root()?.querySelectorAll('#sys-images-list [data-image-select]') || [];
        if (checked) boxes.forEach(cb => { this._selectedImageIds.add(cb.getAttribute('data-image-select')); cb.checked = true; });
        else { this._selectedImageIds.clear(); boxes.forEach(cb => { cb.checked = false; }); }
        this._updateImageSelectionUI();
    }

    // Button label morph (Clear All ↔ Delete Selected (N)) + master tri-state.
    _updateImageSelectionUI() {
        const r = this.root();
        if (!r) return;
        // Drop ids no longer present (e.g. after a refresh removed them).
        const present = new Set(((this.data && this.data.image_list) || []).map(im => im.id));
        this._selectedImageIds = new Set([...this._selectedImageIds].filter(id => present.has(id)));

        const n = this._selectedImageIds.size;
        const label = r.querySelector('#sys-images-clear .clear-btn-label');
        if (label) label.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All';

        const master = r.querySelector('#sys-images-select-all');
        const visible = r.querySelectorAll('#sys-images-list [data-image-select]');
        if (master) {
            if (n === 0 || visible.length === 0) { master.checked = false; master.indeterminate = false; }
            else if (n >= visible.length) { master.checked = true; master.indeterminate = false; }
            else { master.checked = false; master.indeterminate = true; }
        }
    }

    // ---- delete flow (routes through the system_image_rm task) --------------
    async _deleteImages(ids) {
        const list = (this.data && this.data.image_list) || [];
        const byId = new Map(list.map(im => [im.id, im]));
        const targetsAll = ids.map(id => byId.get(id)).filter(Boolean);
        if (!targetsAll.length) return;

        const inUse = targetsAll.filter(im => im.containers > 0);
        const decision = await this._showImageDeleteModal(targetsAll, inUse);
        if (!decision || !decision.confirmed) return;

        // Skip in-use images unless the user forced it.
        const targets = decision.force ? targetsAll : targetsAll.filter(im => im.containers === 0);
        const targetIds = targets.map(im => im.id);
        if (!targetIds.length) {
            window.notificationSystem?.show?.('Nothing removed — selected images are in use. Enable force to remove them.', 'info');
            return;
        }

        if (!window.tasksManager?.router?.routeAction) {
            window.notificationSystem?.show?.('Task system not ready', 'error');
            return;
        }

        this._deleting = true;
        const clearBtn = this.root()?.querySelector('#sys-images-clear');
        if (clearBtn) clearBtn.disabled = true;
        this.root()?.querySelectorAll('[data-image-delete]').forEach(b => { b.disabled = true; });

        const finish = () => {
            this._deleting = false;
            this._selectedImageIds.clear();
            this._load().then(() => this._render());
        };

        try {
            const task = await window.tasksManager.router.routeAction('system_image_rm', { ids: targetIds, force: decision.force });
            const taskId = task && task.id;
            if (taskId) {
                const onDone = (ev) => {
                    if (ev?.detail?.taskId !== taskId) return;
                    window.removeEventListener('taskCompleted', onDone);
                    clearTimeout(fallback);
                    const failed = ev?.detail?.task?.status === 'failed';
                    window.notificationSystem?.show?.(
                        failed ? 'Some images could not be removed (see Tasks)' : 'Images removed',
                        failed ? 'warning' : 'success');
                    finish();
                };
                // Safety net if the completion event is missed.
                const fallback = setTimeout(() => { window.removeEventListener('taskCompleted', onDone); finish(); }, 12000);
                window.addEventListener('taskCompleted', onDone);
            } else {
                setTimeout(finish, 4000);
            }
        } catch (err) {
            this._deleting = false;
            if (clearBtn) clearBtn.disabled = false;
            window.notificationSystem?.show?.(`Could not start image removal: ${err.message || err}`, 'error');
        }
    }

    // Confirmation modal — same eo shell as the Tasks Clear-All modal. Shows a
    // force toggle only when in-use images are in the target set. Resolves
    // {confirmed, force}.
    _showImageDeleteModal(targets, inUse) {
        return new Promise((resolve) => {
            if (!window.openEoModal) { resolve({ confirmed: confirm(`Remove ${targets.length} image(s)?`), force: false }); return; }
            const fmt = window.SystemFmt;
            const total = targets.length;
            const inUseN = inUse.length;
            const freeN = total - inUseN;
            const totalSize = targets.reduce((a, im) => a + (im.size || 0), 0);

            const badges = window.eoBadgeRow ? window.eoBadgeRow([
                { label: `Images: ${total}`, variant: 'info' },
                { label: `Size: ${fmt.bytes(totalSize)}`, variant: 'info' },
                ...(inUseN > 0 ? [{ label: `In use: ${inUseN}`, variant: 'warning' }] : []),
            ]) : '';

            const body = `
                <div class="eo-empty-state danger" role="status">
                    <div class="eo-empty-state-icon">
                        <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="M3 6h18"/><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"/></svg>
                    </div>
                    <div class="eo-empty-state-body">
                        <p class="eo-empty-state-title">This cannot be undone</p>
                        <p class="eo-empty-state-text">Removed images must be pulled or rebuilt to use again.</p>
                    </div>
                </div>
                ${badges}
                ${inUseN > 0 ? `
                    <label class="eo-toggle eo-toggle-card" data-eo-toggle-row>
                        <input type="checkbox" id="img-force">
                        <span class="eo-toggle-track"></span>
                        <span class="eo-toggle-text">
                            <span class="eo-toggle-text-title">Force-remove in-use images</span>
                            <span class="eo-toggle-text-help">Off: skip the ${inUseN} in-use image${inUseN === 1 ? '' : 's'} (remove the other ${freeN}). On: force-remove them — this can break a running app until it's re-pulled.</span>
                        </span>
                    </label>` : ''}`;

            let decided = false;
            const finish = (val, modal) => { if (decided) return; decided = true; if (modal) modal.close(); resolve(val); };
            const m = window.openEoModal({
                id: 'remove-images-modal',
                size: 'sm',
                eyebrow: 'Remove Images',
                title: total === 1 ? 'Remove this image?' : `Remove ${total} images?`,
                desc: 'Confirm to remove the selected image(s) from the Docker engine.',
                body,
                actions: [
                    { label: 'Remove', variant: 'danger', onClick: (modal) => {
                        const cb = modal.bodyEl.querySelector('#img-force');
                        finish({ confirmed: true, force: !!(cb && cb.checked) }, modal);
                    } },
                    { label: 'Cancel', variant: 'secondary', onClick: (modal) => finish({ confirmed: false }, modal) },
                ],
                onClose: () => finish({ confirmed: false }, null),
            });

            // Live-update the danger button label as the force toggle flips.
            const cb = m.bodyEl.querySelector('#img-force');
            const btn = m.contentEl.querySelector('.btn-danger');
            const sync = () => {
                if (!btn) return;
                const force = !!(cb && cb.checked);
                if (inUseN > 0 && !force) btn.textContent = freeN > 0 ? `Remove ${freeN} (skip ${inUseN} in use)` : `Nothing to remove`;
                else btn.textContent = total === 1 ? 'Remove Image' : `Remove ${total} Images`;
            };
            if (cb) cb.addEventListener('change', sync);
            sync();
        });
    }
}

window.SystemStoragePage = SystemStoragePage;
