pragma Singleton

// User accounts, through accountsservice -- the daemon GNOME's Users panel
// drives, so what this changes is what every other login surface reads.
//
// Every change is authorized by polkit, which prompts through the agent this
// session already runs. A refused prompt is a normal outcome, not an error to
// apologize for, and it comes back as a plain sentence.
//
// No password ever crosses this file. Setting one writes it to the helper's
// stdin, which is the only path that keeps it off a command line.

import Quickshell
import Quickshell.Io
import QtQuick

Singleton {
    id: root

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

    property var users: []
    property string currentUser: ""
    property int administratorCount: 0
    property bool scanned: false
    property string lastError: ""

    // Guards read the Process objects rather than a derived "busy" binding. A
    // binding hands back its cached value inside the handler that changes it,
    // which silently turns a refresh after a successful change into a no-op --
    // the write lands and the page never notices. See DefaultApps.qml.
    readonly property bool busy: query.running || mutation.running || passwordWrite.running

    readonly property var me: {
        for (const user of root.users) {
            if (user.userName === root.currentUser)
                return user;
        }
        return root.users.length > 0 ? root.users[0] : null;
    }

    readonly property var others: root.users.filter(user => user.userName !== root.currentUser)

    // Bumped once a picture has actually been set, and only then.
    //
    // accountsservice writes every avatar to the same path, so choosing a new
    // picture leaves iconFile byte-identical and the URL never changes. Qt keys
    // its image cache on that URL, so the old picture stayed on screen and the
    // page looked broken while the write had in fact succeeded. The fragment
    // moves the cache key without changing the file the URL resolves to.
    property int iconRevision: 0

    // Set while a picture is being written, so the revision is bumped for a
    // genuine change rather than on every refresh that happens to pass through.
    property bool settingIcon: false

    // The avatar, as a URL the shell can draw, or "" when none is set.
    readonly property string avatarUrl: root.me && String(root.me.iconFile ?? "") !== ""
        ? "file://" + root.me.iconFile + "#v" + root.iconRevision
        : ""

    function displayName(user: var): string {
        const real = String(user?.realName ?? "").trim();
        return real !== "" ? real : String(user?.userName ?? "");
    }

    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.users = Array.isArray(parsed.users) ? parsed.users : [];
            root.currentUser = String(parsed.currentUser ?? "");
            root.administratorCount = Number(parsed.administratorCount ?? 0);
            root.lastError = String(parsed.error ?? "");
            if (root.settingIcon) {
                root.settingIcon = false;
                if (root.lastError === "")
                    root.iconRevision += 1;
            }
        } catch (error) {
            root.lastError = "Could not read the account service's answer.";
            console.warn("Accounts: 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 setRealName(userName: string, name: string): void {
        root.run(["set-real-name", userName, name]);
    }

    function setIcon(userName: string, path: string): void {
        root.settingIcon = true;
        root.run(["set-icon", userName, path]);
    }

    // The same, from a square chosen in the picture's own pixels.
    function setIconCropped(userName: string, path: string,
                            x: int, y: int, size: int): void {
        root.settingIcon = true;
        root.run(["set-icon", userName, path,
                  String(Math.round(x)), String(Math.round(y)), String(Math.round(size))]);
    }

    function setAccountType(userName: string, kind: string): void {
        root.run(["set-account-type", userName, kind]);
    }

    function setAutomaticLogin(userName: string, enabled: bool): void {
        root.run(["set-automatic-login", userName, enabled ? "true" : "false"]);
    }

    function createUser(userName: string, realName: string, kind: string): void {
        root.run(["create-user", userName, realName, kind]);
    }

    function deleteUser(userName: string, removeFiles: bool): void {
        root.run(["delete-user", userName, removeFiles ? "remove-files" : "keep-files"]);
    }

    // The password goes to the helper's stdin and nowhere else: never an
    // argument, because argv is readable by every process on this machine.
    //
    // It is written from onStarted rather than here, because a process has no
    // stdin to write to until it is actually running. The same pattern
    // HomeAssistantConfig uses for its token.
    property string pendingPassword: ""

    function setPassword(userName: string, password: string): void {
        if (passwordWrite.running)
            return;
        root.lastError = "";
        root.pendingPassword = password;
        passwordWrite.command = [root.helperPath, "set-password", userName];
        passwordWrite.running = true;
    }

    // Self-initializing: the Control Center draws the avatar too, and a
    // singleton is constructed on first use, so whichever surface asks first
    // gets a populated service without having to know to ask.
    Component.onCompleted: root.refresh()

    Process {
        id: query
        stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
        stderr: StdioCollector {
            onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
        }
    }

    Process {
        id: passwordWrite
        stdinEnabled: true
        onStarted: {
            passwordWrite.write(root.pendingPassword + "\n");
            // Held for as long as it takes to hand over, and no longer.
            root.pendingPassword = "";
            passwordWrite.stdinEnabled = false;
        }
        stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
        stderr: StdioCollector {
            onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
        }
        onExited: root.pendingPassword = ""
    }

    Process {
        id: mutation
        // The helper answers with the fresh state, so the page updates from the
        // mutation itself and never has to ask again.
        stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
        stderr: StdioCollector {
            onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
        }
    }
}
