pragma Singleton

// Application-facing desktop style.
//
// Panama owns the durable choices; gsettings is an output boundary for GTK
// and applications that follow GNOME's desktop schemas. Commands are arrays,
// values are validated before storage, and no user text is ever sent through a
// shell. Hyprland's pointer setting stays live through Accessibility.

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

Singleton {
    id: root

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

    // SearchPicker consumes [{ value, label, detail }]. Keep the raw names as
    // a separate allow-list so a caller cannot smuggle a display label into a
    // stored theme name.
    property var cursorThemes: []
    property var iconThemes: []
    property var cursorThemeNames: []
    property var iconThemeNames: []
    property bool catalogLoaded: false
    property bool scanning: false
    property bool startupApplied: false
    property string lastError: ""

    property var pending: []
    readonly property bool busy: root.scanning || catalogProcess.running
        || runner.running || root.pending.length > 0
    readonly property int preferenceRevision: DesktopPreferences.revision

    readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
    readonly property string iconTheme: DesktopPreferences.get("iconTheme")
    readonly property string applicationFont: DesktopPreferences.get("applicationFont")
    readonly property string documentFont: DesktopPreferences.get("documentFont")
    readonly property string monospaceFont: DesktopPreferences.get("monospaceFont")

    Process {
        id: catalogProcess

        stdout: StdioCollector {
            onStreamFinished: root.acceptCatalog(this.text)
        }

        onExited: (exitCode, exitStatus) => {
            root.scanning = false;
            if (exitCode !== 0) {
                root.catalogLoaded = false;
                root.lastError = "Installed icon and pointer themes could not be read.";
            }
        }
    }

    Process {
        id: runner

        onExited: (exitCode, exitStatus) => {
            if (exitCode !== 0)
                root.lastError = "One desktop style setting could not be applied.";
            root.drain();
        }
    }

    Component.onCompleted: {
        root.ensureStarted();
        // Accessing the singleton here keeps its existing hyprctl setcursor
        // path alive for cursor-theme changes as well as cursor-size changes.
        Accessibility.applyAll();
    }

    Timer {
        id: startupApply
        interval: 1200
        onTriggered: {
            root.startupApplied = true;
            root.applyAll();
        }
    }

    Connections {
        target: DesktopPreferences
        function onRevisionChanged(): void { applyCoalesce.restart(); }
    }

    Timer {
        id: applyCoalesce
        interval: 180
        onTriggered: root.applyAll()
    }

    function ensureStarted(): void {
        if (!root.catalogLoaded && !root.scanning)
            root.refreshCatalog();
        if (!root.startupApplied && !startupApply.running)
            startupApply.restart();
    }

    function refreshCatalog(): void {
        if (catalogProcess.running)
            return;
        root.scanning = true;
        catalogProcess.exec([root.helperPath]);
    }

    function acceptCatalog(text: string): void {
        try {
            const parsed = JSON.parse(text);
            if (!parsed || !Array.isArray(parsed.cursorThemes) || !Array.isArray(parsed.iconThemes))
                throw new Error("invalid catalog shape");

            const cursors = parsed.cursorThemes.filter(name =>
                typeof name === "string" && PreferenceSchema.coerce("cursorTheme", name) !== undefined);
            const icons = parsed.iconThemes.filter(name =>
                typeof name === "string" && PreferenceSchema.coerce("iconTheme", name) !== undefined);
            root.cursorThemeNames = cursors;
            root.iconThemeNames = icons;
            root.cursorThemes = cursors.map(name => ({
                value: name,
                label: name,
                detail: "Pointer theme"
            }));
            root.iconThemes = icons.map(name => ({
                value: name,
                label: name,
                detail: "Application icon theme"
            }));
            root.catalogLoaded = true;
            root.lastError = "";
        } catch (error) {
            root.cursorThemes = [];
            root.iconThemes = [];
            root.cursorThemeNames = [];
            root.iconThemeNames = [];
            root.catalogLoaded = false;
            root.lastError = "Installed icon and pointer themes could not be read.";
        }
        root.scanning = false;
    }

    function drain(): void {
        if (runner.running || root.pending.length === 0)
            return;
        const next = root.pending[0];
        root.pending = root.pending.slice(1);
        runner.exec(next);
    }

    function enqueue(commands: var): void {
        // A fresh revision supersedes commands that have not started yet. The
        // currently running command is allowed to finish, then the newest full
        // state is replayed in a deterministic order.
        root.pending = commands;
        root.drain();
    }

    // GVariant accepts JSON-style quoted strings. JSON.stringify escapes every
    // quote, backslash, and control character, and the schema patterns further
    // constrain stored font/theme names. Arguments still travel directly to
    // gsettings rather than through a shell.
    function gvariant(value: var): string {
        if (typeof value === "boolean")
            return value ? "true" : "false";
        if (typeof value === "number")
            return String(value);
        return JSON.stringify(String(value));
    }

    function fontName(familyKey: string, sizeKey: string): string {
        return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
    }

    function buttonLayout(): string {
        const side = DesktopPreferences.get("titlebarButtonSide");
        const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;

        // Tokens are fixed. Only their side and whether maximize is present
        // vary, so preference data can never become command syntax.
        if (side === "left")
            return (maximize ? "close,maximize" : "close") + ":appmenu";
        return "appmenu:" + (maximize ? "maximize,close" : "close");
    }

    function setting(schema: string, key: string, value: var): var {
        return ["gsettings", "set", schema, key, root.gvariant(value)];
    }

    function applyAll(): void {
        root.lastError = "";
        root.enqueue([
            root.setting("org.gnome.desktop.interface", "icon-theme", root.iconTheme),
            root.setting("org.gnome.desktop.interface", "cursor-theme", root.cursorTheme),
            root.setting("org.gnome.desktop.interface", "font-name",
                         root.fontName("applicationFont", "applicationFontSize")),
            root.setting("org.gnome.desktop.interface", "document-font-name",
                         root.fontName("documentFont", "documentFontSize")),
            root.setting("org.gnome.desktop.interface", "monospace-font-name",
                         root.fontName("monospaceFont", "monospaceFontSize")),
            root.setting("org.gnome.desktop.interface", "font-hinting",
                         DesktopPreferences.get("fontHinting")),
            root.setting("org.gnome.desktop.interface", "font-antialiasing",
                         DesktopPreferences.get("fontAntialiasing")),
            root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
                         DesktopPreferences.get("middleClickPaste")),
            root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
            root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
                         DesktopPreferences.get("titlebarDoubleClick")),
            // The portal republishes this as org.freedesktop.appearance
            // contrast, which is what libadwaita reads -- so this reaches GTK4
            // applications without any high-contrast theme being installed.
            root.setting("org.gnome.desktop.a11y.interface", "high-contrast",
                         DesktopPreferences.get("highContrast"))
        ]);
    }

    function storeCatalogChoice(key: string, value: string, allowed: var, kind: string): bool {
        if (!root.catalogLoaded || allowed.indexOf(value) < 0) {
            root.lastError = `That ${kind} theme is not installed.`;
            return false;
        }
        if (!DesktopPreferences.set(key, value)) {
            root.lastError = `That ${kind} theme name could not be saved.`;
            return false;
        }
        root.lastError = "";
        return true;
    }

    function setCursorTheme(value: string): bool {
        return root.storeCatalogChoice("cursorTheme", value, root.cursorThemeNames, "pointer");
    }

    function setIconTheme(value: string): bool {
        return root.storeCatalogChoice("iconTheme", value, root.iconThemeNames, "icon");
    }

    function storeFont(key: string, family: string, allowed: var, kind: string): bool {
        if (allowed.indexOf(family) < 0) {
            root.lastError = `That ${kind} font is not installed.`;
            return false;
        }
        if (!DesktopPreferences.set(key, family)) {
            root.lastError = `That ${kind} font name could not be saved.`;
            return false;
        }
        root.lastError = "";
        return true;
    }

    function setApplicationFont(family: string): bool {
        return root.storeFont("applicationFont", family, Fonts.interfaceFonts, "application");
    }

    function setDocumentFont(family: string): bool {
        return root.storeFont("documentFont", family, Fonts.interfaceFonts, "document");
    }

    function setMonospaceFont(family: string): bool {
        return root.storeFont("monospaceFont", family, Fonts.monospaceFonts, "monospace");
    }
}
