pragma Singleton

// Rootless podman-compose stacks: what they are, what they expose, what they cost.
//
// Grouping is read from the compose labels rather than invented, and acting on a
// group is done with plain podman over the labelled set -- never
// `podman-compose down`, which would remove containers this shell did not
// create. The compose file is the source of truth for what exists, and it
// belongs to the repository.
//
// Rootless throughout, so nothing here prompts for a password.

import Quickshell
import Quickshell.Io
import QtQuick

Singleton {
    id: root

    readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-containers"

    // How many log lines are kept in memory. A busy container can emit
    // thousands a minute, and the panel is for reading the recent past, not for
    // archiving it.
    readonly property int logLimit: 2000

    property bool available: false
    property var projects: []
    property var loose: []
    property int running: 0
    property int total: 0
    property var exposed: []
    property var disk: ({})
    property bool scanned: false
    property string lastError: ""

    // Guards read these Processes directly. A derived `busy` binding returns its
    // cached value inside the handler that changes its dependency, which is how
    // a write can be dropped without any error at all.
    readonly property bool busy: query.running || mutation.running

    // Published on every interface AND currently running: reachable now, as
    // opposed to a stopped container that merely would be.
    readonly property var reachable: root.exposed.filter(entry => entry.running === true)
    readonly property var wouldExpose: root.exposed.filter(entry => entry.running !== true)

    readonly property var unusedImages: root.disk.unusedImages ?? []
    readonly property var unusedVolumes: root.disk.unusedVolumes ?? []
    readonly property int reclaimable:
        Number(root.disk.imagesReclaimable ?? 0) + Number(root.disk.volumesReclaimable ?? 0)

    // The container whose logs are on screen, and whether podman is still
    // feeding them. Empty when the log view is closed.
    property string logTarget: ""
    readonly property bool logFollowing: logs.running
    property string logError: ""

    readonly property alias logLines: logModel

    // Decimal units, to match what podman itself prints.
    function formatBytes(bytes: real): string {
        if (!(bytes > 0))
            return "0 B";
        const units = ["B", "KB", "MB", "GB", "TB"];
        let value = bytes;
        let index = 0;
        while (value >= 1000 && index < units.length - 1) {
            value /= 1000;
            index += 1;
        }
        return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index];
    }

    // Paths are shown relative to home: the interesting part of a compose path
    // is where it sits in the repository, not the eight characters before it.
    function shorten(path: string): string {
        const home = Quickshell.env("HOME") ?? "";
        return home !== "" && path.startsWith(home + "/") ? "~" + path.slice(home.length) : path;
    }

    // Hand a compose file to whatever opens text files -- which on this machine
    // is Neovim in kitty.
    function openFile(path: string): void {
        if (path === "")
            return;
        opener.command = ["xdg-open", path];
        opener.running = true;
    }

    function refresh(): void {
        if (query.running)
            return;
        query.command = [root.helperPath, "snapshot"];
        query.running = true;
    }

    function absorb(text: string): void {
        try {
            const parsed = JSON.parse(text);
            root.available = parsed.available === true;
            root.projects = Array.isArray(parsed.projects) ? parsed.projects : [];
            root.loose = Array.isArray(parsed.loose) ? parsed.loose : [];
            root.running = Number(parsed.running ?? 0);
            root.total = Number(parsed.total ?? 0);
            root.exposed = Array.isArray(parsed.exposed) ? parsed.exposed : [];
            root.disk = parsed.disk ?? ({});
            root.lastError = String(parsed.error ?? "");
        } catch (error) {
            root.lastError = "Could not read the state of the containers.";
            console.warn("Containers: could not parse helper output:", error);
        }
        root.scanned = true;
    }

    function run(arguments: var): void {
        if (mutation.running)
            return;
        root.lastError = "";
        mutation.command = [root.helperPath].concat(arguments);
        mutation.running = true;
    }

    function start(name: string): void { root.run(["start", name]); }
    function stop(name: string): void { root.run(["stop", name]); }
    function restart(name: string): void { root.run(["restart", name]); }

    function startProject(name: string): void { root.run(["project-start", name]); }
    function stopProject(name: string): void { root.run(["project-stop", name]); }
    function restartProject(name: string): void { root.run(["project-restart", name]); }

    function pruneImages(): void { root.run(["prune-images"]); }
    function pruneVolumes(): void { root.run(["prune-volumes"]); }

    // Bind a service's published ports to loopback by editing its compose file
    // in place. The helper adds an address and leaves everything else alone, or
    // refuses; it never rewrites a port it cannot read unambiguously.
    function bindLocal(project: string, service: string): void {
        root.run(["bind-local", project, service]);
    }

    // ── logs ────────────────────────────────────────────────────────────────

    function openLogs(name: string): void {
        root.closeLogs();
        root.logTarget = name;
        root.logError = "";
        // --tail bounds the initial burst: a container running for weeks would
        // otherwise deliver its entire history before the first line appears.
        logs.command = ["podman", "logs", "--tail", "400", "--timestamps", "--follow", name];
        logs.running = true;
    }

    function closeLogs(): void {
        if (logs.running)
            logs.running = false;
        logModel.clear();
        root.logTarget = "";
        root.logError = "";
    }

    function appendLog(line: string): void {
        if (root.logTarget === "")
            return;
        logModel.append({ line });
        if (logModel.count > root.logLimit)
            logModel.remove(0, logModel.count - root.logLimit);
    }

    Component.onCompleted: root.refresh()

    ListModel { id: logModel }

    Process {
        id: query
        stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
        stderr: StdioCollector {
            onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
        }
    }

    Process {
        id: mutation
        stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
        stderr: StdioCollector {
            onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
        }
    }

    Process { id: opener }

    Process {
        id: logs
        // podman writes container output to both streams; a log view that showed
        // only one would silently drop half of what the container said.
        stdout: SplitParser { onRead: line => root.appendLog(line) }
        stderr: SplitParser { onRead: line => root.appendLog(line) }
        onExited: (code, status) => {
            if (root.logTarget !== "" && code !== 0)
                root.logError = "The log stream ended unexpectedly.";
        }
    }
}
