pragma Singleton

// ─────────────────────────────────────────────────────────────────────────────
// Battery and power source.
//
// GNOME put this in the system menu; here it is a bar indicator and a card on
// the Power page. The steady state is pure sysfs reads, following Vitals.qml:
// no subprocess runs on the timer.
//
// Which files to read is the one thing QML cannot work out for itself, because
// it cannot glob -- a battery is BAT0 on most machines, BAT1 on some, and the
// mains supply is AC, AC0, ADP1 or ACAD depending on firmware. So
// scripts/panama-battery resolves the names once at startup and this reads
// them directly from then on.
//
// `available` is the flag every consumer gates on, exactly as Vitals exposes
// gpuAvailable. A desktop has no battery and the correct behavior there is
// that nothing appears at all -- which is why nothing here falls back to a
// plausible-looking zero.
//
// One deliberate omission: no time-to-empty estimate. The kernel's own figure
// swings wildly under load and computing one from a discharge rate produces a
// confident number that is usually wrong, which is worse than no number.
// ─────────────────────────────────────────────────────────────────────────────

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

Singleton {
    id: root

    // Percent charged, 0-100. Meaningless unless `available`.
    property real percent: 0

    // "Charging" | "Discharging" | "Full" | "Not charging" | "Unknown"
    property string status: "Unknown"

    // On wall power. Independent of charging: a full battery on the charger is
    // not charging but is very much on AC, and the idle timings care about the
    // wall rather than the current. True on a machine with no mains supply at
    // all, because a desktop cannot run out of power.
    property bool acOnline: true

    // False until a battery has actually been read.
    property bool available: false

    // The firmware charge ceiling, and whether this machine has one at all.
    // Not every laptop does, and the Power page hides the control rather than
    // offering one that would lie.
    property int chargeLimit: 0
    property bool chargeLimitSupported: false

    readonly property bool charging: root.status === "Charging"
    readonly property bool low: root.available && !root.acOnline
        && root.percent <= Settings.batteryLowPercent
    readonly property bool critical: root.available && !root.acOnline
        && root.percent <= Settings.batteryCriticalPercent

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

    // Resolved once. Empty means this machine has none.
    property string batteryPath: ""
    property string mainsPath: ""
    property string thresholdPath: ""
    property bool located: false

    signal acChanged(bool online)

    function refresh(): void {
        if (!root.located) {
            locate.running = true;
            return;
        }
        capacityFile.reload();
        statusFile.reload();
        if (root.mainsPath !== "")
            onlineFile.reload();
        if (root.thresholdPath !== "")
            thresholdFile.reload();
    }

    function setChargeLimit(percent: int): void {
        if (!root.chargeLimitSupported || applyLimit.running)
            return;
        applyLimit.command = [root.helperPath, "set-threshold", String(percent)];
        applyLimit.running = true;
    }

    // A battery moves a percentage point every few minutes; a charger being
    // unplugged is the only fast transition, and 20s catches it well inside
    // the time any timing decision matters. Small file reads, no subprocess.
    Timer {
        interval: 20000
        running: root.available || !root.located
        repeat: true
        triggeredOnStart: true
        onTriggered: root.refresh()
    }

    Process {
        id: locate
        command: [root.helperPath, "paths"]
        stdout: StdioCollector {
            onStreamFinished: {
                root.located = true;
                try {
                    const paths = JSON.parse(text);
                    root.batteryPath = String(paths.battery ?? "");
                    root.mainsPath = String(paths.mains ?? "");
                    root.thresholdPath = String(paths.threshold ?? "");
                    root.chargeLimitSupported = root.thresholdPath !== "";
                } catch (error) {
                    console.warn("Battery: could not read the sysfs paths:", error);
                    root.available = false;
                    return;
                }
                if (root.batteryPath === "") {
                    root.available = false;
                    return;
                }
                root.refresh();
            }
        }
    }

    Process {
        id: applyLimit
        // Read back rather than trusting the write: some firmware clamps the
        // value or ignores it entirely.
        onExited: root.refresh()
    }

    FileView {
        id: capacityFile
        path: root.batteryPath === "" ? "" : root.batteryPath + "/capacity"
        printErrors: false
        onLoaded: {
            const value = parseInt(text().trim(), 10);
            if (isFinite(value)) {
                root.percent = Math.max(0, Math.min(100, value));
                root.available = true;
            }
        }
        // It was there and stopped reading: a removable pack, or a path that
        // moved. Drop availability rather than showing the last number
        // forever, and re-resolve on the next tick.
        onLoadFailed: {
            root.available = false;
            root.located = false;
        }
    }

    FileView {
        id: statusFile
        path: root.batteryPath === "" ? "" : root.batteryPath + "/status"
        printErrors: false
        onLoaded: root.status = text().trim() || "Unknown"
        onLoadFailed: root.status = "Unknown"
    }

    FileView {
        id: onlineFile
        path: root.mainsPath === "" ? "" : root.mainsPath + "/online"
        printErrors: false
        onLoaded: {
            const online = text().trim() === "1";
            if (online !== root.acOnline) {
                root.acOnline = online;
                // The idle timings differ by power source and hypridle has no
                // concept of one, so somebody has to say when it changed.
                root.acChanged(online);
            }
        }
    }

    FileView {
        id: thresholdFile
        path: root.thresholdPath
        printErrors: false
        onLoaded: {
            const value = parseInt(text().trim(), 10);
            if (isFinite(value))
                root.chargeLimit = value;
        }
        onLoadFailed: root.chargeLimitSupported = false;
    }

    // The charge ceiling is a preference the firmware has to be told about.
    // Written the same way IdleLock regenerates hypridle: watch for the value
    // changing, debounce, then push it -- and only when it actually differs
    // from what the hardware reports, so a settled slider does not ask for a
    // password on every unrelated preference write.
    Connections {
        target: DesktopPreferences
        function onRevisionChanged(): void {
            if (root.chargeLimitSupported)
                limitSync.restart();
        }
    }

    Timer {
        id: limitSync
        interval: 600
        onTriggered: {
            const wanted = DesktopPreferences.get("batteryChargeLimit");
            if (wanted !== root.chargeLimit)
                root.setChargeLimit(wanted);
        }
    }

    Component.onCompleted: root.refresh()
}
