// Read-only Docker inspection routes for the Admin → System deep-dive pages.
//
//   GET /api/system/containers
//     List every container with a per-app summary (compose-project grouping
//     mirrors what metrics_apps.json does, but with extra per-container
//     fields the deep-dive pages need: state, status, image, ports, mounts,
//     restart count). Cached for STAT_TTL_MS.
//
//   GET /api/system/containers/:id
//     Full container detail straight from `docker inspect`. Includes
//     resource limits, mounts, networks, env, health-check state, restart
//     policy. Cached briefly so the page can poll without thrashing the
//     daemon.
//
//   GET /api/system/containers/:id/stats
//     One-shot live stats sample (one frame of /containers/<id>/stats).
//     Returns the same shape Docker emits, plus a derived `cpu_percent`
//     and `mem_percent` so the frontend doesn't have to recompute.
//
//   GET /api/system/containers/:id/logs?tail=N
//     Last N lines of combined stdout/stderr, multiplex-decoded.
//
//   GET /api/system/storage
//     `docker system df` — total + reclaimable per category (images,
//     containers, volumes, build cache). Cached for STORAGE_TTL_MS because
//     this is one of the more expensive calls on a busy daemon.
//
// Mounted at /api/system in routes.js (so paths are /api/system/containers
// etc.). Uses the shared docker util (utils/docker.js) which talks to the
// bind-mounted unix socket — no `docker` CLI inside the container, no
// extra deps.

const express = require('express');
const { dockerRequest, dockerStream, decodeMultiplexedLog, DOCKER_SOCKET } = require('../utils/docker.js');

const router = express.Router();

const STAT_TTL_MS = 1500;
const STORAGE_TTL_MS = 5000;
const LIST_TTL_MS = 1500;

// Trivial per-key TTL cache so concurrent tabs don't pile up daemon calls.
function makeTtlCache(ttl) {
    const m = new Map();
    return {
        async get(key, loader) {
            const now = Date.now();
            const hit = m.get(key);
            if (hit && (now - hit.at) < ttl) return hit.value;
            const value = await loader();
            m.set(key, { value, at: now });
            return value;
        },
        invalidate(key) { m.delete(key); },
    };
}

const listCache = makeTtlCache(LIST_TTL_MS);
const statCache = makeTtlCache(STAT_TTL_MS);
const inspectCache = makeTtlCache(STAT_TTL_MS);
const storageCache = makeTtlCache(STORAGE_TTL_MS);

// Container id slug used on the wire. Real Docker ids are 64-char hex; the
// short form is the first 12 chars. We accept either, plus container
// *names* (which can include /., _, -). Anything else is rejected.
const SAFE_CONTAINER_REF = /^[a-zA-Z0-9_.\-]{1,128}$/;
function safeRef(s) { return typeof s === 'string' && SAFE_CONTAINER_REF.test(s); }

function reduceContainer(c) {
    // Compose project label tells us which "app" (in LibrePortal terms) a
    // container belongs to. Containers without that label fall back to
    // their own name as a single-container app.
    const labels = c.Labels || {};
    const project = labels['com.docker.compose.project'] || (c.Names && c.Names[0] ? c.Names[0].replace(/^\//, '') : null);
    const service = labels['com.docker.compose.service'] || null;
    const name = (c.Names && c.Names[0]) ? c.Names[0].replace(/^\//, '') : c.Id?.slice(0, 12) || '';
    const networks = c.NetworkSettings && c.NetworkSettings.Networks
        ? Object.entries(c.NetworkSettings.Networks).map(([n, v]) => ({ name: n, ip: v?.IPAddress || null }))
        : [];
    const ports = Array.isArray(c.Ports)
        ? c.Ports.map(p => ({ ip: p.IP || null, host: p.PublicPort || null, container: p.PrivatePort, proto: p.Type }))
        : [];
    return {
        id: c.Id,
        short: (c.Id || '').slice(0, 12),
        name,
        image: c.Image,
        image_id: c.ImageID,
        project,
        service,
        state: c.State,
        status: c.Status,
        created: c.Created,                 // epoch seconds
        labels,
        ports,
        networks,
        mounts: Array.isArray(c.Mounts) ? c.Mounts.map(m => ({
            type: m.Type,
            source: m.Source,
            target: m.Destination,
            mode: m.Mode,
            rw: m.RW,
        })) : [],
    };
}

// CPU% from a Docker stats frame. The daemon emits cumulative CPU usage in
// nanoseconds plus the system-wide CPU time; the percentage is the delta
// over the previous frame normalised by online CPUs. The first call has no
// prev frame, so Docker conveniently sends both `cpu_stats` (current) and
// `precpu_stats` (previous) in every frame.
function cpuPercent(s) {
    const cpu = s.cpu_stats, pre = s.precpu_stats;
    if (!cpu || !pre) return 0;
    const cpuDelta = (cpu.cpu_usage?.total_usage || 0) - (pre.cpu_usage?.total_usage || 0);
    const sysDelta = (cpu.system_cpu_usage || 0) - (pre.system_cpu_usage || 0);
    const onlineCpus = cpu.online_cpus
        || (cpu.cpu_usage?.percpu_usage?.length)
        || 1;
    if (sysDelta <= 0 || cpuDelta < 0) return 0;
    return +((cpuDelta / sysDelta) * onlineCpus * 100).toFixed(2);
}

function memUsage(s) {
    const m = s.memory_stats || {};
    const usage = m.usage || 0;
    // Docker counts page-cache in `usage`; the "real" working set excludes
    // cached memory. Matches what `docker stats` shows.
    const cache = (m.stats && (m.stats.cache || m.stats.total_inactive_file)) || 0;
    const used = Math.max(0, usage - cache);
    const limit = m.limit || 0;
    return {
        used, cache, limit,
        percent: limit ? +((used / limit) * 100).toFixed(2) : 0,
    };
}

function netUsage(s) {
    const nets = s.networks || {};
    let rx = 0, tx = 0;
    for (const v of Object.values(nets)) {
        rx += v.rx_bytes || 0;
        tx += v.tx_bytes || 0;
    }
    return { rx_total: rx, tx_total: tx };
}

function blkioUsage(s) {
    const b = (s.blkio_stats && s.blkio_stats.io_service_bytes_recursive) || [];
    let read = 0, write = 0;
    for (const e of b) {
        if (e.op === 'Read' || e.op === 'read') read += e.value || 0;
        else if (e.op === 'Write' || e.op === 'write') write += e.value || 0;
    }
    return { read, write };
}

function pidsUsage(s) {
    const p = s.pids_stats || {};
    return { current: p.current || 0, limit: p.limit || 0 };
}

// ---------------------------------------------------------------------------

router.get('/containers', async (req, res) => {
    if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
    try {
        const list = await listCache.get('all', () =>
            dockerRequest('GET', '/containers/json', { all: 'true' })
        );
        const containers = (Array.isArray(list) ? list : []).map(reduceContainer);
        // Group by project for convenience — frontend uses both shapes.
        const byApp = new Map();
        for (const c of containers) {
            const key = c.project || c.name;
            if (!byApp.has(key)) byApp.set(key, []);
            byApp.get(key).push(c);
        }
        const apps = [...byApp.entries()].map(([app, members]) => {
            const running = members.filter(c => c.state === 'running').length;
            return { app, containers: members.length, running, members };
        }).sort((a, b) => b.running - a.running || a.app.localeCompare(b.app));
        res.set('Cache-Control', 'no-store');
        res.json({ containers, apps, updated: new Date().toISOString() });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

router.get('/containers/:id', async (req, res) => {
    if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
    const { id } = req.params;
    if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
    try {
        const detail = await inspectCache.get(`inspect:${id}`, () =>
            dockerRequest('GET', `/containers/${encodeURIComponent(id)}/json`)
        );
        if (!detail) return res.status(404).json({ error: 'not_found' });
        // Project the verbose inspect payload down to what the deep-dive
        // page actually wants. Keeps the wire small and the frontend
        // contract stable.
        const host = detail.HostConfig || {};
        const state = detail.State || {};
        const cfg = detail.Config || {};
        const netSettings = detail.NetworkSettings || {};
        const out = {
            id: detail.Id,
            short: (detail.Id || '').slice(0, 12),
            name: (detail.Name || '').replace(/^\//, ''),
            image: cfg.Image || detail.Image,
            image_id: detail.Image,
            created: detail.Created,
            project: (cfg.Labels && cfg.Labels['com.docker.compose.project']) || null,
            service: (cfg.Labels && cfg.Labels['com.docker.compose.service']) || null,
            labels: cfg.Labels || {},
            state: {
                status: state.Status,
                running: !!state.Running,
                paused: !!state.Paused,
                restarting: !!state.Restarting,
                oom_killed: !!state.OOMKilled,
                dead: !!state.Dead,
                pid: state.Pid || 0,
                exit_code: state.ExitCode ?? null,
                error: state.Error || '',
                started_at: state.StartedAt || null,
                finished_at: state.FinishedAt || null,
                restart_count: detail.RestartCount || 0,
                health: state.Health ? {
                    status: state.Health.Status,
                    failing_streak: state.Health.FailingStreak,
                    log: (state.Health.Log || []).slice(-5).map(l => ({
                        start: l.Start, end: l.End, exit_code: l.ExitCode, output: (l.Output || '').slice(0, 800)
                    })),
                } : null,
            },
            limits: {
                memory:    host.Memory || 0,            // bytes; 0 = unlimited
                memory_swap: host.MemorySwap || 0,
                memory_reservation: host.MemoryReservation || 0,
                cpu_shares: host.CpuShares || 0,
                cpu_quota: host.CpuQuota || 0,          // microseconds in CpuPeriod
                cpu_period: host.CpuPeriod || 0,
                nano_cpus: host.NanoCpus || 0,          // 1e9 = 1 cpu
                pids: host.PidsLimit || 0,
                restart_policy: (host.RestartPolicy && host.RestartPolicy.Name) || 'no',
                restart_max: (host.RestartPolicy && host.RestartPolicy.MaximumRetryCount) || 0,
            },
            mounts: (detail.Mounts || []).map(m => ({
                type: m.Type,
                source: m.Source,
                target: m.Destination,
                mode: m.Mode,
                rw: m.RW,
            })),
            networks: netSettings.Networks
                ? Object.entries(netSettings.Networks).map(([name, v]) => ({
                    name,
                    ip: v?.IPAddress || null,
                    mac: v?.MacAddress || null,
                    gateway: v?.Gateway || null,
                }))
                : [],
            ports: netSettings.Ports
                ? Object.entries(netSettings.Ports).flatMap(([k, bindings]) => {
                    const [containerPort, proto] = k.split('/');
                    if (!Array.isArray(bindings) || !bindings.length) {
                        return [{ container: parseInt(containerPort, 10), proto, host: null, ip: null }];
                    }
                    return bindings.map(b => ({
                        container: parseInt(containerPort, 10),
                        proto,
                        host: b.HostPort ? parseInt(b.HostPort, 10) : null,
                        ip: b.HostIp || null,
                    }));
                })
                : [],
        };
        res.set('Cache-Control', 'no-store');
        res.json(out);
    } catch (err) {
        if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
        res.status(500).json({ error: err.message });
    }
});

router.get('/containers/:id/stats', async (req, res) => {
    if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
    const { id } = req.params;
    if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
    try {
        const sample = await statCache.get(`stats:${id}`, () =>
            dockerRequest('GET', `/containers/${encodeURIComponent(id)}/stats`, { stream: 'false' })
        );
        if (!sample) return res.status(404).json({ error: 'not_found' });
        res.set('Cache-Control', 'no-store');
        res.json({
            t: Date.now(),
            cpu_percent: cpuPercent(sample),
            memory: memUsage(sample),
            network: netUsage(sample),
            blkio: blkioUsage(sample),
            pids: pidsUsage(sample),
        });
    } catch (err) {
        if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
        res.status(500).json({ error: err.message });
    }
});

router.get('/containers/:id/logs', async (req, res) => {
    if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
    const { id } = req.params;
    if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
    const tail = Math.max(1, Math.min(2000, parseInt(req.query.tail, 10) || 200));
    try {
        const { stream } = await dockerStream(`/containers/${encodeURIComponent(id)}/logs`, {
            stdout: 'true', stderr: 'true', tail: String(tail), timestamps: 'true',
        });
        const chunks = [];
        for await (const c of stream) chunks.push(c);
        const text = decodeMultiplexedLog(Buffer.concat(chunks));
        res.set('Cache-Control', 'no-store');
        res.type('text/plain').send(text);
    } catch (err) {
        if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
        res.status(500).json({ error: err.message });
    }
});

router.get('/storage', async (req, res) => {
    if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
    try {
        const df = await storageCache.get('df', () => dockerRequest('GET', '/system/df'));
        if (!df) return res.status(500).json({ error: 'no_data' });
        // Roll the verbose response up into headline numbers per category.
        const sumImages = (df.Images || []).reduce(
            (a, im) => {
                a.count++;
                a.size += im.Size || 0;
                a.shared += im.SharedSize || 0;
                if (!im.Containers || im.Containers <= 0) a.reclaimable += im.Size || 0;
                return a;
            },
            { count: 0, size: 0, shared: 0, reclaimable: 0 }
        );
        const sumContainers = (df.Containers || []).reduce(
            (a, c) => {
                a.count++;
                a.size += c.SizeRw || 0;
                if (c.State && c.State !== 'running') a.reclaimable += c.SizeRw || 0;
                return a;
            },
            { count: 0, size: 0, reclaimable: 0 }
        );
        const sumVolumes = (df.Volumes || []).reduce(
            (a, v) => {
                const sz = (v.UsageData && v.UsageData.Size) || 0;
                const refs = (v.UsageData && v.UsageData.RefCount) || 0;
                a.count++;
                a.size += sz;
                if (refs <= 0) a.reclaimable += sz;
                return a;
            },
            { count: 0, size: 0, reclaimable: 0 }
        );
        const sumBuild = (df.BuildCache || []).reduce(
            (a, b) => {
                a.count++;
                a.size += b.Size || 0;
                if (!b.InUse) a.reclaimable += b.Size || 0;
                return a;
            },
            { count: 0, size: 0, reclaimable: 0 }
        );
        // Largest images for "what's eating disk" surface; cap to 10 to
        // keep the payload tight.
        const topImages = (df.Images || [])
            .slice()
            .sort((a, b) => (b.Size || 0) - (a.Size || 0))
            .slice(0, 10)
            .map(im => ({
                id: im.Id,
                repo_tags: im.RepoTags || [],
                size: im.Size || 0,
                shared_size: im.SharedSize || 0,
                containers: im.Containers || 0,
                created: im.Created,
            }));
        // Largest volumes — reclaimable or otherwise. Useful when /docker
        // is hitting 90%+.
        const topVolumes = (df.Volumes || [])
            .slice()
            .sort((a, b) => ((b.UsageData?.Size) || 0) - ((a.UsageData?.Size) || 0))
            .slice(0, 10)
            .map(v => ({
                name: v.Name,
                driver: v.Driver,
                size: (v.UsageData && v.UsageData.Size) || 0,
                ref_count: (v.UsageData && v.UsageData.RefCount) || 0,
            }));
        const total = sumImages.size + sumContainers.size + sumVolumes.size + sumBuild.size;
        const reclaimable = sumImages.reclaimable + sumContainers.reclaimable + sumVolumes.reclaimable + sumBuild.reclaimable;
        res.set('Cache-Control', 'no-store');
        res.json({
            total, reclaimable,
            images: sumImages,
            containers: sumContainers,
            volumes: sumVolumes,
            build_cache: sumBuild,
            top_images: topImages,
            top_volumes: topVolumes,
            updated: new Date().toISOString(),
        });
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});

module.exports = router;
