pragma Singleton

// The keymap, read from the compositor rather than restated.
//
// The Shortcuts page used to hold a hand-typed array of nineteen entries while
// keybinds.lua produced a hundred and thirteen. It could not show the other
// ninety-four, and it drifted the moment a bind was edited. `hyprctl binds -j`
// is the only description of the keymap that cannot be wrong, so this reads
// that and every bind carries its own human label (see the `description`
// argument in hypr/keybinds.lua).
//
// Refreshed on demand, not polled: binds only change when the config is
// reloaded, and nothing in this shell should wake up to re-read something that
// has not moved.

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

Singleton {
    id: root

    // [{ chord, description, group, mouse, repeating, locked }], ordered as
    // Hyprland reports them, which follows the order they appear in the config.
    property var binds: []
    property bool loaded: false
    property string lastError: ""

    readonly property bool busy: query.running

    // Hyprland's modmask bits. SUPER is the Panama modifier.
    readonly property var modifierBits: [
        { bit: 64, name: "Super" },
        { bit: 4, name: "Ctrl" },
        { bit: 8, name: "Alt" },
        { bit: 1, name: "Shift" }
    ]

    // Keysyms whose raw names would be noise in a shortcuts list.
    readonly property var keyNames: ({
        "mouse_up": "Scroll up",
        "mouse_down": "Scroll down",
        "mouse:272": "Left click",
        "mouse:273": "Right click",
        "mouse:274": "Middle click",
        "bracketleft": "[",
        "bracketright": "]",
        "grave": "`",
        "Print": "Print Screen",
        "left": "←",
        "right": "→",
        "up": "↑",
        "down": "↓"
    })

    Process {
        id: query
        command: ["hyprctl", "-j", "binds"]
        stdout: StdioCollector {
            onStreamFinished: root.parse(this.text)
        }
        onExited: (exitCode, exitStatus) => {
            if (exitCode !== 0)
                root.lastError = "Could not read the keymap from Hyprland.";
        }
    }

    // ── Rebinding ───────────────────────────────────────────────────────────
    // Overrides map a SHIPPED chord to a replacement. hypr/keybinds.lua reads
    // them and substitutes only the chord -- the action is always the Lua value
    // written in that file -- so an override can move a shortcut but can never
    // make one do something else.
    //
    // Applying needs `hyprctl reload` rather than a live `hl.bind`: Hyprland
    // reports Lua-defined binds with dispatcher "__lua" and a bytecode offset,
    // so the action cannot be reconstructed from the outside to re-bind it.
    // Reload re-runs the config, which re-reads the settings file.
    readonly property var overrides: {
        const stored = DesktopPreferences.get("keybindOverrides");
        return (stored && typeof stored === "object") ? stored : ({});
    }

    property bool reloading: false

    // The chord a bind ships with, given the chord it currently answers to.
    // Displayed binds come from the compositor and so already reflect any
    // override; the override map is what tells us where they started.
    function shippedChordFor(currentChord: string): string {
        for (const shipped in root.overrides) {
            if (root.overrides[shipped] === currentChord)
                return shipped;
        }
        return currentChord;
    }

    function isOverridden(currentChord: string): bool {
        return root.shippedChordFor(currentChord) !== currentChord;
    }

    // Refuses a chord already answering to something else, so rebinding cannot
    // quietly shadow an existing shortcut.
    function conflictFor(chord: string, exceptCurrent: string): string {
        for (const bind of root.binds) {
            if (bind.luaChord === chord && bind.luaChord !== exceptCurrent)
                return bind.description;
        }
        return "";
    }

    // A moved shortcut vacates its shipped chord, so another override may use
    // it legitimately. Refuse to reset the first shortcut until that occupant
    // moves away; otherwise Hyprland would receive two binds on one chord.
    function overrideOccupantFor(chord: string, exceptShipped: string): string {
        for (const shipped in root.overrides) {
            if (shipped !== exceptShipped && root.overrides[shipped] === chord)
                return shipped;
        }
        return "";
    }

    function rebind(currentChord: string, newChord: string): bool {
        if (newChord === "" || newChord === currentChord)
            return false;

        const conflict = root.conflictFor(newChord, currentChord);
        if (conflict !== "") {
            root.lastError = `${newChord} is already ${conflict}.`;
            return false;
        }

        const shipped = root.shippedChordFor(currentChord);
        const next = Object.assign({}, root.overrides);
        if (newChord === shipped)
            delete next[shipped];
        else
            next[shipped] = newChord;

        if (!DesktopPreferences.set("keybindOverrides", next)) {
            root.lastError = "That shortcut could not be saved.";
            return false;
        }
        root.applyReload();
        return true;
    }

    function resetBind(currentChord: string): bool {
        const shipped = root.shippedChordFor(currentChord);
        if (shipped === currentChord)
            return true;

        const occupant = root.overrideOccupantFor(shipped, shipped);
        if (occupant !== "") {
            root.lastError = `${shipped} is used by another rebound shortcut. Reset that shortcut first.`;
            return false;
        }

        const next = Object.assign({}, root.overrides);
        delete next[shipped];
        if (!DesktopPreferences.set("keybindOverrides", next)) {
            root.lastError = "That shortcut could not be reset.";
            return false;
        }
        root.applyReload();
        return true;
    }

    function resetAll(): void {
        if (Object.keys(root.overrides).length === 0)
            return;
        DesktopPreferences.set("keybindOverrides", ({}));
        root.applyReload();
    }

    Process {
        id: reloadRun
        command: ["hyprctl", "reload"]
        onExited: (exitCode, exitStatus) => {
            root.reloading = false;
            if (exitCode !== 0) {
                root.lastError = "The compositor did not reload.";
                return;
            }
            root.lastError = "";
            // The settings file is written on a timer, so re-read the keymap
            // once the reload has had a moment to pick it up.
            settle.restart();
        }
    }

    Timer {
        id: settle
        interval: 350
        onTriggered: root.refresh()
    }

    function applyReload(): void {
        if (reloadRun.running)
            return;
        root.reloading = true;
        // Give DesktopPreferences' coalescing write a moment to land first.
        reloadDelay.restart();
    }

    Timer {
        id: reloadDelay
        interval: 120
        onTriggered: reloadRun.running = true
    }

    Component.onCompleted: root.refresh()

    function refresh(): void {
        if (query.running)
            return;
        root.lastError = "";
        query.running = true;
    }

    function parse(text: string): void {
        try {
            const raw = JSON.parse(text);
            const out = [];
            for (const bind of raw) {
                const description = String(bind.description ?? "").trim();
                // A bind with no description cannot be presented usefully --
                // the dispatcher is "__lua" and the argument is a bytecode
                // offset. Showing the chord alone would be worse than omitting
                // it, and tests/quickshell/keybinds-contract fails the build
                // if any exist, so this should never be reached in practice.
                if (description === "")
                    continue;
                out.push({
                    chord: root.formatChord(bind),
                    // The same chord in the form hypr/keybinds.lua writes, which
                    // is what an override is keyed by. The display form
                    // prettifies modifiers and arrow keys and so cannot be used
                    // for that.
                    luaChord: root.luaChord(bind),
                    description: description,
                    group: root.groupFor(description, bind),
                    mouse: bind.mouse === true,
                    repeating: bind.repeat === true,
                    locked: bind.locked === true
                });
            }
            root.binds = out;
            root.loaded = true;
            root.lastError = "";
        } catch (error) {
            root.lastError = "The keymap could not be read.";
        }
    }

    // "SUPER + SHIFT + K" -- uppercase modifiers in the order keybinds.lua
    // writes them, then the raw keysym rather than its display name.
    function luaChord(bind: var): string {
        const parts = [];
        for (const modifier of root.modifierBits) {
            if ((bind.modmask & modifier.bit) !== 0)
                parts.push(modifier.name.toUpperCase());
        }
        parts.push(String(bind.key ?? ""));
        return parts.join(" + ");
    }

    function formatChord(bind: var): string {
        const parts = [];
        for (const modifier of root.modifierBits) {
            if ((bind.modmask & modifier.bit) !== 0)
                parts.push(modifier.name);
        }
        const key = String(bind.key ?? "");
        parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key));
        return parts.join(" + ");
    }

    // Grouping is by what the shortcut does, taken from its own description,
    // so adding a bind puts it in the right section without touching this file.
    // Which section a bind belongs to.
    //
    // "Windows" used to catch focus, movement, splitting, resizing and window
    // state alike, which put 43 of the 93 binds under one heading -- a section
    // that long is a list, not a grouping. The window verbs are separated here
    // by what you are actually trying to do.
    //
    // Order matters: "Next window splits down" is about splitting rather than
    // focus, and "Focus session" is a Panama feature rather than window focus,
    // so both are settled before the general checks below them.
    function groupFor(description: string, bind: var): string {
        const text = description.toLowerCase();
        if (bind.key && String(bind.key).indexOf("XF86") === 0)
            return "Media & hardware keys";

        // Quiet mode and Caffeine bound to a workspace, not window focus.
        if (text.indexOf("focus session") >= 0)
            return "Applications & shell";

        if (text.indexOf("workspace") >= 0)
            return "Workspaces";

        if (text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
            || text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
            || text.indexOf("shrink") >= 0 || text.indexOf("expand") >= 0
            || text.indexOf("grow") >= 0 || text.indexOf("resize") >= 0)
            return "Size";

        if (text.indexOf("split") >= 0 || text.indexOf("swap") >= 0
            || text.indexOf("move window") >= 0)
            return "Move & split";

        if (text.indexOf("close") >= 0 || text.indexOf("fullscreen") >= 0
            || text.indexOf("float") >= 0 || text.indexOf("pin ") >= 0
            || text.indexOf("scratchpad") >= 0 || text.indexOf("minimize") >= 0)
            return "Window state";

        if (text.indexOf("focus") >= 0 || text.indexOf("next window") >= 0
            || text.indexOf("previous window") >= 0 || text.indexOf("last window") >= 0
            || text.indexOf("window switch") >= 0)
            return "Focus";

        if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
            || text.indexOf("track") >= 0 || text.indexOf("play") >= 0
            || text.indexOf("brightness") >= 0)
            return "Media & hardware keys";

        return "Applications & shell";
    }

    // Section order for the page. Anything a future bind invents lands at the
    // end rather than being dropped.
    readonly property var groupOrder: ["Focus", "Move & split", "Size", "Window state",
                                      "Workspaces", "Applications & shell", "Media & hardware keys"]

    // The action already bound to a chord, or "" if it is free. Compared on the
    // form keybinds.lua writes rather than the prettified display form, because
    // that is what a rebind is keyed by -- "SUPER + Q" and "Super+Q" are the
    // same binding and must not read as two.
    function boundTo(luaChord: string, exceptLuaChord: string): string {
        const wanted = String(luaChord).replace(/\s+/g, "").toLowerCase();
        const skip = String(exceptLuaChord).replace(/\s+/g, "").toLowerCase();
        for (const bind of root.binds) {
            const candidate = String(bind.luaChord).replace(/\s+/g, "").toLowerCase();
            if (candidate === wanted && candidate !== skip)
                return String(bind.description);
        }
        return "";
    }

    function grouped(): var {
        const buckets = {};
        for (const bind of root.binds) {
            buckets[bind.group] = buckets[bind.group] ?? [];
            buckets[bind.group].push(bind);
        }
        const names = Object.keys(buckets).sort((a, b) => {
            const ia = root.groupOrder.indexOf(a);
            const ib = root.groupOrder.indexOf(b);
            return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
        });
        return names.map(name => ({ name: name, binds: buckets[name] }));
    }
}
