pragma Singleton

// Snapshots of Panama's durable settings stores.
//
// DesktopPreferences and HomePreferences use separate files. The helper owns
// the transactional filesystem boundary; this service owns settling the live
// desktop after those files have changed underneath it.
//
// HomePreferences intentionally keeps its FileView in Quickshell's private
// state directory while snapshots use Panama's canonical state directory. This
// service bridges them through HomePreferences' public mutation API, then soft
// reloads once external consumers have settled.

import Quickshell
import Quickshell.Io
import QtQuick
import qs.config

Singleton {
    id: root

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

    property var snapshots: []
    property string lastError: ""
    property string lastAction: ""

    // Narrow service boundaries keep restore sequencing explicit and make it
    // possible to verify the real handler in an isolated shell without ever
    // calling the daily-driver compositor or wallpaper services.
    property var readHomeState: function() {
        return {
            initialized: HomePreferences.initialized,
            favorites: HomePreferences.favorites
        };
    }
    property var resetHome: function() { HomePreferences.resetHomeDefaults(); }
    property var initializeHome: function(ids) { HomePreferences.initialize(ids); }
    property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
    property var reloadDesktop: function() { DesktopPreferences.reload(); }
    property var readDisplays: function() { return DesktopPreferences.get("displays"); }
    property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
    property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
    property var readLiveDisplayLayout: function() { return Displays.currentLayout(); }
    property var applyDisplayLayout: function(layout) { return Displays.applyProtectedLayout(layout); }
    property var displayCanConfirm: function() { return Displays.canConfirm; }
    property var confirmDisplayLayout: function() { return Displays.confirm(); }
    property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
    property var applyIdle: function() { IdleLock.apply(); }
    property var idleBusy: function() { return IdleLock.busy; }
    property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
    property var reloadKeybinds: function() { Keybinds.applyReload(); }
    property var keybindsReloading: function() { return Keybinds.reloading; }
    property var systemBusy: function() { return SystemSettings.busy; }
    property var applyWallpaperPolicy: function() { Wallpaper.applyCurrentPolicy(false); }
    property var wallpaperBusy: function() { return Wallpaper.busy; }
    property var regenerateLock: function() { LockScreen.regenerate(); }
    property var lockBusy: function() { return LockScreen.busy; }
    property var reloadShell: function() { Quickshell.reload(false); }
    property var protectedDisplays: ({})
    property var protectedDisplayLayout: []
    property var pendingRestoredLayout: null

    readonly property bool busy: listQuery.running || actionRun.running
        || settleDisplayRestore.running || applyRestoredState.running || settleReload.running

    Process {
        id: listQuery
        command: [root.helperPath, "list"]
        stdout: StdioCollector {
            onStreamFinished: {
                try {
                    const parsed = JSON.parse(this.text);
                    root.snapshots = Array.isArray(parsed) ? parsed : [];
                    if (root.lastError === "Could not read the list of snapshots.")
                        root.lastError = "";
                } catch (error) {
                    root.lastError = "Could not read the list of snapshots.";
                }
            }
        }
    }

    Process {
        id: actionRun
        property bool restoring: false
        property string outputText: ""
        stdout: StdioCollector {
            onStreamFinished: actionRun.outputText = this.text
        }
        onStarted: actionRun.outputText = ""
        onExited: (exitCode, exitStatus) => {
            if (exitCode !== 0) {
                root.lastError = actionRun.restoring
                    ? "That snapshot could not be restored."
                    : "The settings could not be backed up.";
                if (actionRun.restoring) {
                    root.setDisplayBlocked(false);
                    root.protectedDisplays = ({});
                    root.protectedDisplayLayout = [];
                }
                return;
            }
            root.lastAction = actionRun.restoring ? "restored" : "saved";
            if (actionRun.restoring) {
                const restoreAccepted = root.handleRestoreOutput(actionRun.outputText);
                if (restoreAccepted)
                    root.lastError = "";
                else if (root.lastError === "")
                    root.lastError = "Desktop settings were restored, but Home favorites could not be reloaded.";
                if (!restoreAccepted) {
                    root.setDisplayBlocked(false);
                    root.protectedDisplays = ({});
                    root.protectedDisplayLayout = [];
                }
            } else
                root.lastError = "";
            root.refresh();
        }
    }

    Timer {
        id: settleDisplayRestore
        property int attempts: 0
        interval: 100
        repeat: true
        onTriggered: {
            attempts++;
            if (root.displayCanConfirm()) {
                stop();
                if (!root.confirmDisplayLayout()) {
                    root.failDisplayRestore("The restored display layout could not be confirmed.");
                    return;
                }
                root.pendingRestoredLayout = null;
                root.beginRestoredStateReplay();
            } else if (!root.displayBusy() || attempts >= 180) {
                stop();
                root.failDisplayRestore("The restored display layout could not be verified.");
            }
        }
    }

    Timer {
        id: applyRestoredState
        interval: 80
        repeat: false
        onTriggered: {
            // DesktopPreferences.reload() invalidates reactive shell bindings.
            // These services also own state outside QML and need an explicit
            // replay: compositor options, Lua-generated binds, and hyprpaper.
            root.applyIdle();
            root.regenerateLock();
            root.applyWallpaperPolicy();
            root.reloadKeybinds();
            root.applyCompositor();

            settleReload.attempts = 0;
            settleReload.restart();
        }
    }

    Timer {
        id: settleReload
        property int attempts: 0
        interval: 100
        repeat: true
        onTriggered: {
            attempts++;
            // Let the current instances finish their external writes before a
            // soft reload replaces them. The cap keeps a failed external tool
            // from leaving restored Home state stale indefinitely.
            if ((!root.idleBusy() && !root.keybindsReloading() && !root.systemBusy()
                    && !root.wallpaperBusy() && !root.lockBusy()) || attempts >= 30) {
                stop();
                root.setDisplayBlocked(false);
                root.protectedDisplays = ({});
                root.protectedDisplayLayout = [];
                root.reloadShell();
            }
        }
    }

    Component.onCompleted: {
        root.refresh();

        // Restoring defaults is the only irreversible action Panama offers, and
        // it lives in SystemSettings -- which must not reference this singleton,
        // since this one already references it. So the capability is PUSHED
        // there rather than pulled from here.
        //
        // Returns false when a snapshot is already running rather than queueing:
        // the caller is about to wipe the stores, and a snapshot landing after
        // that would record the wiped state as if it were the user's.
        SystemSettings.takeSafetySnapshot = function() {
            if (actionRun.running)
                return false;
            root.save();
            return true;
        };
    }

    function refresh(): void {
        if (!listQuery.running)
            listQuery.running = true;
    }

    function save(): void {
        if (actionRun.running)
            return;
        actionRun.restoring = false;
        actionRun.exec([root.helperPath, "save", root.serializeHomeState()]);
    }


    function serializeHomeState(): string {
        const current = root.readHomeState();
        const favorites = [];
        for (const favorite of current.favorites ?? []) {
            favorites.push({
                id: String(favorite.id ?? ""),
                alias: String(favorite.alias ?? "")
            });
        }
        return JSON.stringify({
            initialized: current.initialized === true,
            favorites: favorites
        });
    }

    function handleRestoreOutput(text: string): bool {
        if (!root.reloadHomeState(text))
            return false;
        root.reloadDesktop();
        const restoredLayout = root.layoutFromStoredDisplays(root.readDisplays());
        if (restoredLayout === null || root.layoutsEqual(
                restoredLayout, root.protectedDisplayLayout)) {
            root.beginRestoredStateReplay();
            return true;
        }
        root.pendingRestoredLayout = restoredLayout;
        if (!root.applyDisplayLayout(restoredLayout))
            return root.failDisplayRestore("The restored display layout was rejected.");
        settleDisplayRestore.attempts = 0;
        settleDisplayRestore.restart();
        return true;
    }

    function beginRestoredStateReplay(): void {
        applyRestoredState.restart();
    }

    function layoutFromStoredDisplays(stored: var): var {
        if (!stored || typeof stored !== "object")
            return null;
        const current = root.readLiveDisplayLayout();
        if (!Array.isArray(current) || current.length === 0)
            return null;
        const layout = [];
        for (const live of current) {
            const entry = stored[live.name];
            const match = String(entry?.mode ?? "").match(
                /^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
            if (!entry || !match || !Number.isFinite(entry.scale) || entry.scale <= 0
                    || !Number.isInteger(entry.transform)
                    || entry.transform < 0 || entry.transform > 3
                    || !Number.isInteger(entry.x) || !Number.isInteger(entry.y)
                    || typeof entry.primary !== "boolean")
                return null;
            layout.push(Object.assign({}, live, {
                width: Number(match[1]),
                height: Number(match[2]),
                refreshRate: Number(match[3]),
                mode: entry.mode,
                scale: entry.scale,
                transform: entry.transform,
                x: entry.x,
                y: entry.y,
                primary: entry.primary
            }));
        }
        return layout.filter(record => record.primary).length === 1 ? layout : null;
    }

    function layoutsEqual(left: var, right: var): bool {
        if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
            return false;
        const fields = ["name", "mode", "scale", "transform", "x", "y", "primary"];
        const a = Array.from(left).sort((x, y) => x.name.localeCompare(y.name));
        const b = Array.from(right).sort((x, y) => x.name.localeCompare(y.name));
        return a.every((record, index) => fields.every(
            field => record[field] === b[index][field]));
    }

    function failDisplayRestore(message: string): bool {
        root.pendingRestoredLayout = null;
        if (!root.protectDisplays(root.protectedDisplays))
            message += " The original display preference also could not be restored.";
        root.setDisplayBlocked(false);
        root.protectedDisplays = ({});
        root.protectedDisplayLayout = [];
        root.lastError = message;
        return false;
    }

    // Restore output carries the canonical Home state. Reconstructing through
    // these methods keeps validation and persistence inside HomePreferences;
    // this service never mutates its aliases or private FileView directly.
    function reloadHomeState(text: string): bool {
        try {
            const result = JSON.parse(text);
            const restored = result?.home;
            if (!restored || restored.preserve === true)
                return true;

            if (restored.present !== true)
                return restored.present === false
                    ? root.resetHomeState()
                    : false;

            const data = restored.data;
            if (!data || typeof data.initialized !== "boolean" || !Array.isArray(data.favorites))
                return false;
            const ids = [];
            const aliases = [];
            const seen = {};
            for (const favorite of data.favorites) {
                const id = favorite?.id;
                const alias = favorite?.alias;
                if (typeof id !== "string" || !/^light\.[a-z0-9_]+$/.test(id)
                        || typeof alias !== "string" || seen[id])
                    return false;
                seen[id] = true;
                ids.push(id);
                aliases.push(alias);
            }
            if (!data.initialized && ids.length > 0)
                return false;

            root.resetHome();
            if (!data.initialized)
                return true;
            root.initializeHome(ids);
            for (let index = 0; index < ids.length; index++)
                root.aliasHome(ids[index], aliases[index]);
            return true;
        } catch (error) {
            return false;
        }
    }

    function resetHomeState(): bool {
        root.resetHome();
        return true;
    }

    // The name is matched against the snapshot list rather than trusted, so no
    // caller-supplied path reaches the helper even though it validates as well.
    function restore(name: string): bool {
        if (actionRun.running)
            return false;
        if (root.displayBusy()) {
            root.lastError = "Finish the current display change before restoring settings.";
            return false;
        }
        if (!root.snapshots.some(snapshot => snapshot.name === name)) {
            root.lastError = "That snapshot is not in the list.";
            return false;
        }
        const currentDisplays = root.readDisplays();
        root.protectedDisplays = JSON.parse(JSON.stringify(
            currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
        root.protectedDisplayLayout = JSON.parse(JSON.stringify(
            root.readLiveDisplayLayout() ?? []));
        root.setDisplayBlocked(true);
        actionRun.restoring = true;
        actionRun.exec([root.helperPath, "restore", name]);
        return true;
    }
}
