pragma Singleton

// The allow-listed machine boundary for Panama Settings. Visual pages call
// these methods; no UI text is ever interpolated into a shell command.

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

Singleton {
    id: root

    property string monitorName: ""
    property string monitorDescription: ""
    property int monitorWidth: 0
    property int monitorHeight: 0
    property real monitorRefreshRate: 0
    property real monitorScale: 1
    property string monitorFormat: ""
    property string colorPreset: ""
    property bool monitorVrrActive: false

    property bool nextcloudActive: false
    property bool rustdeskActive: false
    property bool kdeconnectActive: false
    property bool hyprpaperActive: false
    property bool hypridleActive: false
    property bool vicinaeActive: false
    property bool bluebubblesDetected: false

    property string hyprlandVersion: ""
    property string quickshellVersion: "0.3.0"
    property string lastError: ""

    // Explicit seams keep reset sequencing testable without changing the live
    // keymap, wallpaper, or display from an isolated contract harness.
    property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
    property var readDisplays: function() { return DesktopPreferences.get("displays"); }
    property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }

    // Installed by SettingsBackup at startup. A default no-op rather than a
    // direct reference, because SettingsBackup already references this
    // singleton and a mutual reference between two singletons is an
    // initialisation-order problem waiting to happen. Tests override it the
    // same way they override the seams above.
    property var takeSafetySnapshot: function() { return false; }
    property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
    property var reloadKeybinds: function() { Keybinds.applyReload(); }
    property var keybindsReloading: function() { return Keybinds.reloading; }
    property var applyWallpaper: function(path) { Wallpaper.set(path); }
    property var regenerateLock: function() { LockScreen.regenerate(); }
    property var lockBusy: function() { return LockScreen.busy; }

    readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
        || configWrite.running || configVerify.running || bluebubblesQuery.running

    readonly property bool bluebubblesAvailable: root.bluebubblesDetected

    readonly property bool autoHdr: DesktopPreferences.get("autoHdr")
    readonly property int vrrPolicy: DesktopPreferences.get("vrrPolicy")
    readonly property int directScanoutPolicy: DesktopPreferences.get("directScanoutPolicy")

    // ── The Hyprland write boundary ─────────────────────────────────────────
    // Every option Panama may write, with the hl.config path used to set it and
    // the getoption path used to read it back. The UI never names an option or
    // supplies a raw value: it calls a setter, which resolves the option here
    // and range-checks the value against `allowed`. Nothing user-supplied is
    // ever interpolated into the payload.
    //
    // `hyprctl keyword` is deliberately NOT used. On a Lua-configured Hyprland
    // it refuses the write, prints "keyword can't work with non-legacy parsers"
    // to stdout, and still exits 0 -- so code branching on the exit status
    // believes it succeeded. `hyprctl eval` has the same hazard: it exits 0 on
    // syntax and runtime errors, reporting them as an "error:" line instead.
    //
    // Success therefore means exactly one thing here: the value was read back
    // from the compositor and matched what was requested.
    //
    // The set of writable options is not restated here: it is every schema
    // entry carrying a `hypr` block. Adding a live-adjustable Hyprland setting
    // is a schema entry plus a prefs.get() call in the Lua, and needs no new
    // code in this file.

    Process {
        id: monitorQuery
        command: ["hyprctl", "-j", "monitors"]
        stdout: StdioCollector {
            onStreamFinished: root.parseMonitors(this.text)
        }
        onExited: (exitCode, exitStatus) => {
            if (exitCode !== 0)
                root.lastError = "Could not read the active display.";
        }
    }

    Process {
        id: serviceQuery
        command: [
            "bash", "-lc",
            "printf '{\"nextcloud\":%s,\"rustdesk\":%s,\"kdeconnect\":%s,\"hyprpaper\":%s,\"hypridle\":%s,\"vicinae\":%s}\\n' "
                + "$(pgrep -x nextcloud >/dev/null && printf true || printf false) "
                + "$(systemctl is-active --quiet rustdesk.service && printf true || printf false) "
                + "$(pgrep -x kdeconnectd >/dev/null && printf true || printf false) "
                + "$(systemctl --user is-active --quiet hyprpaper.service && printf true || printf false) "
                + "$(systemctl --user is-active --quiet hypridle.service && printf true || printf false) "
                + "$(systemctl --user is-active --quiet vicinae.service && printf true || printf false)"
        ]
        stdout: StdioCollector {
            onStreamFinished: root.parseServices(this.text)
        }
    }

    Process {
        id: versionQuery
        command: ["Hyprland", "--version"]
        stdout: StdioCollector {
            onStreamFinished: {
                const match = this.text.match(/Hyprland\s+([0-9.]+)/);
                root.hyprlandVersion = match ? match[1] : this.text.trim().split("\n")[0];
            }
        }
    }

    // Applies a validated batch of options in one `hl.config{}` call, then hands
    // off to configVerify. Never commits anything on its own: an "ok" here only
    // means Hyprland parsed the payload.
    Process {
        id: configWrite

        // id -> integer value, already validated by applyOptions().
        property var pending: ({})

        stdout: StdioCollector {
            onStreamFinished: {
                if (this.text.indexOf("error:") >= 0) {
                    root.reportWriteFailure(configWrite.pending, this.text);
                    return;
                }
                root.verifyPending();
            }
        }
    }

    Process {
        id: bluebubblesQuery
        command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
        onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0
    }

    // Reads the written options back out of the compositor. This is the only
    // thing that decides whether a write succeeded.
    Process {
        id: configVerify

        stdout: StdioCollector {
            onStreamFinished: root.commitVerified(configWrite.pending, this.text)
        }
    }

    Timer {
        // Preferences load asynchronously from disk. Applying after one quiet
        // second avoids racing their restore and runs only once per shell start.
        interval: 1000
        running: true
        onTriggered: root.applyPersistedDisplayPolicy()
    }

    Component.onCompleted: root.refresh()

    function refresh(): void {
        root.lastError = "";
        if (!monitorQuery.running)
            monitorQuery.running = true;
        if (!serviceQuery.running)
            serviceQuery.running = true;
        if (!versionQuery.running && !root.hyprlandVersion)
            versionQuery.running = true;
        if (!bluebubblesQuery.running)
            bluebubblesQuery.running = true;
    }

    function parseMonitors(text: string): void {
        try {
            const monitors = JSON.parse(text);
            const monitor = monitors.find(item => item.focused) ?? monitors[0];
            if (!monitor)
                throw new Error("No active monitor");
            root.monitorName = monitor.name ?? "";
            root.monitorDescription = monitor.description ?? monitor.model ?? "Display";
            root.monitorWidth = monitor.width ?? 0;
            root.monitorHeight = monitor.height ?? 0;
            root.monitorRefreshRate = monitor.refreshRate ?? 0;
            root.monitorScale = monitor.scale ?? 1;
            root.monitorFormat = monitor.currentFormat ?? "";
            root.colorPreset = monitor.colorManagementPreset ?? "";
            root.monitorVrrActive = monitor.vrr ?? false;
        } catch (error) {
            root.lastError = "The display response could not be read.";
        }
    }

    function parseServices(text: string): void {
        try {
            const state = JSON.parse(text);
            root.nextcloudActive = state.nextcloud === true;
            root.rustdeskActive = state.rustdesk === true;
            root.kdeconnectActive = state.kdeconnect === true;
            root.hyprpaperActive = state.hyprpaper === true;
            root.hypridleActive = state.hypridle === true;
            root.vicinaeActive = state.vicinae === true;
        } catch (error) {
            root.lastError = "Startup-service status could not be read.";
        }
    }

    // ── Applying options ────────────────────────────────────────────────────
    // Test harnesses may replace the external compositor boundary while still
    // exercising validation, commit routing, persistence, and reset replay.
    // Production leaves this unset and always uses the verified Hyprland path.
    property var compositorApplyOverride: null

    // `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
    // The whole batch is validated before anything is sent, so one bad value
    // rejects the batch rather than half-applying it.
    function applyOptions(values: var): bool {
        const requested = {};
        for (const key in values) {
            const entry = PreferenceSchema.spec(key);
            if (!entry || !entry.hypr) {
                root.lastError = "That setting is not applied by the compositor.";
                return false;
            }
            const coerced = PreferenceSchema.coerce(key, values[key]);
            if (coerced === undefined) {
                root.lastError = `Unsupported value for ${entry.label}.`;
                return false;
            }
            requested[key] = coerced;
        }
        if (Object.keys(requested).length === 0)
            return false;

        if (root.compositorApplyOverride !== null)
            return root.compositorApplyOverride(requested);

        // A write in flight is queued rather than refused. Options are applied
        // and verified one batch at a time, but the callers are a settings UI
        // and a startup replay of every compositor-backed preference -- they
        // overlap routinely, and dropping a change on the floor would leave the
        // stored value and the compositor disagreeing. Later values for the
        // same key win.
        if (configWrite.running || configVerify.running) {
            root.queued = Object.assign({}, root.queued, requested);
            return true;
        }

        root.startWrite(requested);
        return true;
    }

    // Merged batches waiting for the current write to finish.
    property var queued: ({})

    function startWrite(requested: var): void {
        configWrite.pending = requested;
        configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
    }

    // Called when a write settles, however it settled. A failed batch must not
    // strand whatever queued up behind it.
    function drainQueue(): void {
        const next = root.queued;
        if (Object.keys(next).length === 0)
            return;
        root.queued = ({});
        root.startWrite(next);
    }

    // The value as Hyprland stores it. Several options are a toggle in the UI
    // but an integer in the compositor (cm_auto_hdr, follow_mouse); `readAs`
    // decides, and config/dot/hypr/prefs.lua does the same conversion via
    // prefs.getInt so both sides agree.
    function hyprValue(entry: var, value: var): var {
        // Some options are phrased as a negative by the compositor -- the four
        // Hyprland notices are all `disable_x` -- while the setting reads as
        // "show x", because a switch labeled "Disable splash text" that must
        // be ON to hide something is a small cruelty. `invert` bridges the two,
        // in exactly one place, so nothing downstream has to remember which
        // options are backwards.
        if (entry.hypr.invert === true && typeof value === "boolean")
            value = !value;

        if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
            return value ? 1 : 0;
        return value;
    }

    // Serializes validated values into a nested hl.config{} call. Table paths
    // come from the schema and values have already passed coerce(), including
    // the pattern check on constrained strings, so nothing caller-supplied
    // reaches the payload unchecked.
    function buildConfigPayload(requested: var): string {
        const tree = {};
        for (const key in requested) {
            const entry = PreferenceSchema.spec(key);
            const path = entry.hypr.path;
            let node = tree;
            for (let i = 0; i < path.length - 1; i++)
                node = node[path[i]] = node[path[i]] ?? {};
            node[path[path.length - 1]] = root.serializeValue(root.hyprValue(entry, requested[key]));
        }
        return `hl.config(${root.serializeTable(tree)})`;
    }

    function serializeValue(value: var): string {
        if (typeof value === "boolean")
            return value ? "true" : "false";
        if (typeof value === "number")
            return String(value);

        // A gradient is the one setting whose Lua form is not a scalar. The
        // stubs declare it as `string|{colors:string[], angle?:number}`, and
        // the string form only ever carries ONE stop -- writing
        // "rgba(a) rgba(b) 45deg" as a string is accepted and silently keeps
        // the previous value, which is how a two-stop write looks like it
        // worked and did nothing. Multi-stop must be the table form.
        if (value && typeof value === "object" && Array.isArray(value.colors)) {
            const stops = value.colors
                .map(stop => `"${String(stop).replace(/["\\]/g, "")}"`)
                .join(", ");
            const angle = Number(value.angle);
            return `{ colors = { ${stops} }` + (isFinite(angle) ? `, angle = ${angle} }` : ` }`);
        }

        // A vec2 reaches Lua as a two-element table.
        if (Array.isArray(value) && value.length === 2)
            return `{ ${Number(value[0])}, ${Number(value[1])} }`;
        // Strings only reach here after the schema's pattern check; quoting is
        // belt-and-braces rather than the primary defense.
        return `"${String(value).replace(/["\\]/g, "")}"`;
    }

    function serializeTable(node: var): string {
        const parts = [];
        for (const name in node) {
            const child = node[name];
            // A leaf arrives pre-serialized as a string; anything else is
            // either a nested section or a structured value (gradient, vec2)
            // that serializeValue knows how to render.
            const rendered = typeof child === "string"
                ? child
                : (Array.isArray(child) || (child && child.colors !== undefined)
                    ? root.serializeValue(child)
                    : root.serializeTable(child));
            parts.push(`${name} = ${rendered}`);
        }
        return `{ ${parts.join(", ")} }`;
    }

    function verifyPending(): void {
        const options = Object.keys(configWrite.pending)
            .map(key => `getoption ${PreferenceSchema.spec(key).hypr.option}`)
            .join(" ; ");
        configVerify.exec(["hyprctl", "-j", "--batch", options]);
    }

    // The compositor's answer is authoritative. Preferences are only updated for
    // options that actually read back with the requested value.
    function commitVerified(requested: var, text: string): void {
        // Each getoption answers with its own flat JSON object, and the field
        // carrying the value depends on the option's type -- int, bool, float,
        // str, or css for the gap box.
        const observed = {};
        for (const block of text.match(/\{[^{}]*\}/g) ?? []) {
            try {
                const parsed = JSON.parse(block);
                if (parsed.option !== undefined)
                    observed[parsed.option] = parsed;
            } catch (error) {
                // A partial line is treated as "not observed", which fails the
                // comparison below rather than being mistaken for success.
            }
        }

        const rejected = [];
        for (const key in requested) {
            const entry = PreferenceSchema.spec(key);
            if (!root.matchesObserved(entry, requested[key], observed[entry.hypr.option])) {
                rejected.push(entry.label);
                continue;
            }
            DesktopPreferences.set(key, requested[key]);
        }

        root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`;
        root.drainQueue();
    }

    // Gradients are written in one notation and read back in another, so they
    // cannot be compared directly the way every other type can.
    //
    //   written:  { colors = { "rgba(3b426199)" }, angle = 45 }
    //   read:     "993b4261 45deg"
    //
    // The stops swap to AARRGGBB order, lose their wrapper, and the angle is
    // always appended even when it was never given. Comparing the raw strings
    // reports every gradient write as rejected, which is what would have
    // happened had this been added with readAs: "str".
    function gradientMatches(expected: var, observed: string): bool {
        if (typeof observed !== "string")
            return false;
        return root.normalizeGradient(expected) === root.normalizeGradient(observed);
    }

    // Both notations reduced to "aarrggbb aarrggbb Ndeg".
    function normalizeGradient(value: var): string {
        const stops = [];
        let angle = 0;

        const readStop = function (text: string): void {
            const rgba = String(text).match(/rgba?\(\s*([0-9a-fA-F]{6,8})\s*\)/);
            if (rgba) {
                let hex = rgba[1].toLowerCase();
                // rgb() has no alpha; the compositor reports it as fully opaque.
                if (hex.length === 6)
                    hex = hex + "ff";
                // RRGGBBAA in, AARRGGBB out.
                stops.push(hex.slice(6, 8) + hex.slice(0, 6));
                return;
            }
            const bare = String(text).match(/^([0-9a-fA-F]{8})$/);
            if (bare) {
                stops.push(bare[1].toLowerCase());
                return;
            }
            const deg = String(text).match(/^(-?[0-9.]+)deg$/);
            if (deg)
                angle = Number(deg[1]);
        };

        if (value && typeof value === "object" && Array.isArray(value.colors)) {
            value.colors.forEach(readStop);
            if (value.angle !== undefined && isFinite(Number(value.angle)))
                angle = Number(value.angle);
        } else {
            String(value).trim().split(/\s+/).forEach(readStop);
        }

        return stops.join(" ") + " " + angle + "deg";
    }

    function matchesObserved(entry: var, value: var, answer: var): bool {
        if (!answer)
            return false;
        const expected = root.hyprValue(entry, value);
        switch (entry.hypr.readAs) {
        case "bool":
            return answer.bool === expected;
        case "int":
            return answer.int === expected;
        case "float":
            // getoption prints six decimal places; compare within that.
            return Math.abs(answer.float - expected) < 1e-5;
        case "str":
            return answer.str === expected;
        case "css":
            // Gaps read back as a box, e.g. "10 10 10 10".
            return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
        case "gradient":
            return root.gradientMatches(expected, answer.gradient);
        case "vec2":
            return Array.isArray(answer.vec2) && Array.isArray(expected)
                && Number(answer.vec2[0]) === Number(expected[0])
                && Number(answer.vec2[1]) === Number(expected[1]);
        }
        return false;
    }

    function reportWriteFailure(requested: var, text: string): void {
        const labels = Object.keys(requested).map(key => PreferenceSchema.spec(key).label);
        root.lastError = `Hyprland rejected ${labels.join(" and ")}.`;
        root.drainQueue();
    }

    // The one entry point the settings UI uses to change any preference.
    //
    // A compositor-backed setting must be applied and verified before it is
    // stored, so that preferences never claim a value Hyprland refused.
    // Everything else is a direct write. Rows bind a schema key and call this;
    // they never need to know which kind they are holding.
    function commitPreference(key: string, value: var): bool {
        const entry = PreferenceSchema.spec(key);
        if (!entry) {
            root.lastError = "That setting is not part of Panama.";
            return false;
        }
        if (entry.hypr) {
            const batch = {};
            batch[key] = value;
            return root.applyOptions(batch);
        }
        return DesktopPreferences.set(key, value);
    }

    // Restores shipped defaults across every store Panama owns, not just the
    // schema. Panama keeps user state in more than one file -- the schema store,
    // the focus session, and the Home accessory arrangement -- and a reset that
    // silently skipped one would be worse than no reset at all.
    //
    // Compositor-backed values are re-applied afterwards, since resetting the
    // stored value does not by itself tell Hyprland anything.
    function restoreDefaults(): bool {
        if (root.displayBusy()) {
            root.lastError = "Finish the current display change before restoring defaults.";
            return false;
        }

        // Snapshot before wiping. Restoring defaults clears every preference
        // and the Home accessory store, and there is no undo for it anywhere in
        // the app -- so the one automatic snapshot Panama takes is the one taken
        // immediately before the only irreversible action it offers.
        //
        // Deliberately not fatal if it fails: a user who asked to reset should
        // get their reset, and a snapshot that could not be written is reported
        // rather than allowed to block the thing they asked for.
        if (!root.takeSafetySnapshot())
            console.warn("SystemSettings: could not snapshot before restoring defaults");

        root.setDisplayBlocked(true);
        DesktopPreferences.resetDesktopDefaults();

        // Do not apply geometry during a reset: doing so would need the same
        // visible confirmation transaction as the Displays page. Clearing the
        // stored records is still important, though, so the next session uses
        // Panama's shipped DP-2 placement and automatic placement elsewhere.

        // Home accessories keep their own store (panama-home.json), so a reset
        // that only cleared the schema store would silently leave a customized
        // favorites list behind while claiming to restore Panama's defaults.
        //
        // HomePreferences owns the write-through boundary so the state file is
        // rewritten before this reset can be considered complete.
        HomePreferences.resetHomeDefaults();

        resettleTimer.restart();
        return true;
    }

    Timer {
        id: resettleTimer
        interval: 60
        onTriggered: {
            root.applyPersistedDisplayPolicy();
            root.reloadKeybinds();
            root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
            root.regenerateLock();
            resetRelease.attempts = 0;
            resetRelease.restart();
        }
    }

    Timer {
        id: resetRelease
        property int attempts: 0
        interval: 100
        repeat: true
        onTriggered: {
            attempts++;
            if ((!root.keybindsReloading() && !root.busy && !root.lockBusy()) || attempts >= 50) {
                stop();
                root.setDisplayBlocked(false);
            }
        }
    }

    function setAutoHdr(enabled: bool): void {
        root.applyOptions({ autoHdr: enabled });
    }

    function setVrrPolicy(policy: int): void {
        root.applyOptions({ vrrPolicy: policy });
    }

    function setDirectScanoutPolicy(policy: int): void {
        root.applyOptions({ directScanoutPolicy: policy });
    }

    // Replays every compositor-owned preference in one batch at shell start, so
    // a value the user changed in Settings survives a reboot even though the
    // Lua config only reads the file once, at launch.
    function applyPersistedDisplayPolicy(): void {
        const values = {};
        for (const entry of PreferenceSchema.hyprEntries())
            values[entry.key] = DesktopPreferences.get(entry.key);
        root.applyOptions(values);
    }

    function isGnomePanelAllowed(panel: string): bool {
        // Verified against `gnome-control-center --list` on this system. A name
        // that panel list does not contain opens nothing and reports an error,
        // so guessing one here would be a silently dead button.
        return [
            "applications", "background", "bluetooth", "color", "display",
            "keyboard", "mouse", "multitasking", "network", "notifications",
            "online-accounts", "power", "printers", "privacy", "search",
            "sharing", "sound", "system", "universal-access", "wacom",
            "wellbeing", "wifi", "wwan"
        ].indexOf(panel) >= 0;
    }

    // `subpage` reaches the panels GNOME 50 nests under System -- users,
    // about, datetime, region -- which its own desktop entries open as
    // `gnome-control-center system users`. Without it, a row labeled "Users"
    // lands on System's front page and leaves the user to navigate, which is
    // most of the way to a broken button.
    function openGnomePanel(panel: string, subpage: string): bool {
        if (!root.isGnomePanelAllowed(panel)) {
            root.lastError = "That GNOME Settings panel is not available.";
            return false;
        }
        const command = ["gnome-control-center", panel];
        if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
            command.push(subpage);
        Quickshell.execDetached({
            command: command,
            environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
        });
        return true;
    }

    // Only System nests panels, and only these. Read off the Exec lines of the
    // gnome-*-panel desktop entries rather than guessed, for the same reason
    // the panel list above was.
    function isGnomeSubpageAllowed(panel: string, subpage: string): bool {
        if (panel !== "system")
            return false;
        return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0;
    }

    function openApplication(id: string): bool {
        const commands = {
            "nextcloud": ["nextcloud"],
            "rustdesk": ["rustdesk"],
            "kdeconnect": ["kdeconnect-app"],
            "mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"],
            "bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"],
            // The Accessibility page's "Start Orca" button. It was calling
            // openApplication("orca") with no "orca" here, so it set an error
            // and launched nothing -- a dead button. orca ships in
            // hyprland-packages, so this just runs it.
            "orca": ["orca"]
        };
        const command = commands[id];
        if (!command) {
            root.lastError = "That application is not managed by Panama Settings.";
            return false;
        }
        Quickshell.execDetached(command);
        return true;
    }
}
