pragma Singleton

// Snapshots: points in time you can go back to, per btrfs subvolume.
//
// snapper was already running on this machine when this was written, hourly,
// for / only -- and /home is a separate subvolume with no configuration, so six
// hundred snapshots existed and not one contained a document. The page leads
// with what is protected and what is not for that reason.
//
// Deliberately absent: rollback. snapper's rollback changes the btrfs default
// subvolume, and an fstab that pins subvol= overrides it, so a rollback would
// report success and change nothing after a reboot. Restoring files and folders
// needs no reboot and cannot affect booting.

import Quickshell
import Quickshell.Io
import QtQuick

Singleton {
    id: root

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

    property var configs: []
    property var unprotected: []
    property bool timelineRunning: false
    property var space: ({})
    property bool scanned: false
    property string lastError: ""

    // The last restore, so the page can say what happened to the file that was
    // already there rather than leaving someone to wonder.
    property var lastRestore: null

    // Browsing state: which snapshot is open, where inside it, and what is there.
    property string browsingConfig: ""
    property int browsingSnapshot: 0
    property string browsingPath: ""
    property var browseEntries: []
    property bool browseTruncated: false
    property bool browsing: false

    // Guards read the Process objects directly; a derived binding is stale
    // inside the handler that changes it. See DefaultApps.qml.
    readonly property bool busy: query.running || mutation.running

    function labelFor(config: var): string {
        const subvolume = String(config?.subvolume ?? "");
        if (subvolume === "/")
            return "System";
        if (subvolume === "/home")
            return "Home";
        return subvolume;
    }

    function describe(config: var): string {
        const snapshots = config?.snapshots ?? [];
        if (!config?.readable)
            return "This account cannot read this configuration";
        if (snapshots.length === 0)
            return config?.timelineEnabled
                ? "Protected — the first snapshot is taken on the hour"
                : "Configured, but automatic snapshots are off";
        const oldest = snapshots[snapshots.length - 1];
        return snapshots.length + " snapshot" + (snapshots.length === 1 ? "" : "s")
            + " · oldest " + String(oldest.date ?? "").replace(/^\w{3} /, "");
    }

    function retentionSummary(config: var): string {
        const limits = config?.limits ?? ({});
        const parts = [];
        if (Number(limits.hourly ?? 0) > 0) parts.push(limits.hourly + " hourly");
        if (Number(limits.daily ?? 0) > 0) parts.push(limits.daily + " daily");
        if (Number(limits.weekly ?? 0) > 0) parts.push(limits.weekly + " weekly");
        return parts.length > 0 ? parts.join(" · ") : "Nothing kept automatically";
    }

    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];
    }

    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.configs = Array.isArray(parsed.configs) ? parsed.configs : [];
            root.unprotected = Array.isArray(parsed.unprotected) ? parsed.unprotected : [];
            root.timelineRunning = parsed.timelineRunning === true;
            root.space = parsed.space ?? ({});
            root.lastError = String(parsed.error ?? "");
            if (parsed.restored)
                root.lastRestore = parsed.restored;
        } catch (error) {
            root.lastError = "Could not read the snapshot service's answer.";
            console.warn("Snapshots: 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 take(config: string, description: string): void {
        root.run(["create", config, description]);
    }

    function remove(config: string, number: int): void {
        root.run(["delete", config, String(number)]);
    }

    function setRetention(config: string, hourly: int, daily: int, weekly: int): void {
        root.run(["set-retention", config, String(hourly), String(daily), String(weekly)]);
    }

    function setTimeline(config: string, enabled: bool): void {
        root.run(["set-timeline", config, enabled ? "true" : "false"]);
    }

    function restore(config: string, number: int, path: string): void {
        root.lastRestore = null;
        root.run(["restore", config, String(number), path]);
    }

    // Browsing is a separate process from the snapshot, because a directory
    // listing inside a terabyte-scale subvolume is not something to do while
    // opening a page.
    function browse(config: string, number: int, path: string): void {
        if (browseProcess.running)
            return;
        root.browsingConfig = config;
        root.browsingSnapshot = number;
        root.browsingPath = path;
        root.browsing = true;
        browseProcess.command = [root.helperPath, "browse", config, String(number), path];
        browseProcess.running = true;
    }

    function closeBrowser(): void {
        root.browsingConfig = "";
        root.browsingSnapshot = 0;
        root.browsingPath = "";
        root.browseEntries = [];
        root.browseTruncated = false;
    }

    // One level up, or out of the browser at the top.
    function browseUp(): void {
        const trimmed = String(root.browsingPath).replace(/\/+$/, "");
        if (trimmed === "") {
            root.closeBrowser();
            return;
        }
        const parent = trimmed.indexOf("/") < 0 ? "" : trimmed.slice(0, trimmed.lastIndexOf("/"));
        root.browse(root.browsingConfig, root.browsingSnapshot, parent);
    }

    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
        // Answers with the fresh state, so the page updates from the change
        // itself rather than asking again afterwards.
        stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
        stderr: StdioCollector {
            onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
        }
    }

    Process {
        id: browseProcess
        stdout: StdioCollector {
            onStreamFinished: {
                try {
                    const parsed = JSON.parse(this.text);
                    root.browseEntries = Array.isArray(parsed.entries) ? parsed.entries : [];
                    root.browseTruncated = parsed.truncated === true;
                    if (String(parsed.error ?? "") !== "") {
                        root.lastError = String(parsed.error);
                        root.browseEntries = [];
                    }
                } catch (error) {
                    root.lastError = "Could not read that folder from the snapshot.";
                }
            }
        }
        onExited: root.browsing = false
    }
}
