pragma Singleton

// Panel brightness for external monitors, over DDC/CI.
//
// brightnessctl covers laptop panels through the kernel's backlight class. A
// desktop driving a DisplayPort monitor has no such device, so it has no
// brightness control at all -- the only way to dim the screen is the buttons on
// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it
// over the same I2C lines that carry EDID.
//
// Two things shape everything here:
//
//   Detection is slow. Probing every I2C bus takes on the order of a second,
//   which is far too slow to sit in front of a settings page opening. It runs
//   once, on demand, and afterwards each display is addressed by its bus number
//   directly.
//
//   Writes are slow AND rate-limited by the monitor's firmware. A slider drag
//   emits values continuously; sending each one produces a queue the panel
//   works through seconds after the user let go, and some monitors drop or
//   garble writes that arrive too fast. So `value` updates immediately for the
//   UI and the hardware write is debounced, with only the latest value sent.
//
// Displays are keyed by DRM connector name (DP-2) so they line up with what
// Hyprland, the Displays page, and the monitor list already call them.

import Quickshell
import Quickshell.Io
import QtQuick

Singleton {
    id: root

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

    // [{ bus, connector, model, value }] where value is 0..100.
    property var displays: []
    property bool scanning: false

    // Empty when everything is fine. Carries the helper's explanation
    // otherwise -- most usefully the udev command that grants I2C access,
    // which is the difference between "brightness is unavailable" and
    // "brightness is one command away".
    property string lastError: ""

    readonly property bool available: root.displays.length > 0

    // True once a scan has completed, however it went. Lets the UI tell "not
    // looked yet" apart from "looked and found nothing", which otherwise render
    // identically and leave a permanently empty panel with no explanation.
    property bool scanned: false

    // Pending writes, keyed by bus. A monitor being dragged accumulates exactly
    // one entry no matter how many values the slider emits.
    property var pending: ({})

    function refresh(): void {
        if (root.scanning)
            return;
        root.scanning = true;
        scan.running = true;
    }

    function displayFor(connector: string): var {
        return root.displays.find(display => display.connector === connector) ?? null;
    }

    // Sets brightness for one display. The stored value moves at once so the
    // slider tracks the pointer; the hardware follows when the drag settles.
    function set(bus: int, percent: int): void {
        const clamped = Math.max(0, Math.min(100, Math.round(percent)));

        root.displays = root.displays.map(display =>
            display.bus === bus ? Object.assign({}, display, { value: clamped }) : display);

        const next = Object.assign({}, root.pending);
        next[String(bus)] = clamped;
        root.pending = next;
        writeDebounce.restart();
    }

    Process {
        id: scan
        command: [root.helperPath, "list"]
        stdout: StdioCollector {
            onStreamFinished: {
                try {
                    const parsed = JSON.parse(this.text);
                    root.displays = Array.isArray(parsed.displays) ? parsed.displays : [];
                    root.lastError = String(parsed.error ?? "");
                } catch (error) {
                    root.displays = [];
                    root.lastError = "Could not read the brightness helper's output.";
                    console.warn("Brightness: could not parse helper output:", error);
                }
                root.scanning = false;
                root.scanned = true;
            }
        }
    }

    // Long enough that a drag produces one write rather than dozens, short
    // enough that a single click still feels immediate.
    Timer {
        id: writeDebounce
        interval: 120
        onTriggered: root.pump()
    }

    // Writes run one at a time, and each is read back.
    //
    // Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil
    // processes talking to the same monitor interleave their exchanges and both
    // can come back with garbage. Read back because a write is not a promise --
    // panels clamp to their own range, ignore values while waking from standby,
    // and drop writes that arrive too quickly. Without the read the slider shows
    // what Panama asked for rather than what the monitor did, which is the same
    // class of lie as trusting `hyprctl keyword` to have applied something.
    property int writingBus: -1

    function pump(): void {
        if (writer.running || reader.running)
            return;

        for (const bus in root.pending) {
            const value = root.pending[bus];
            const remaining = Object.assign({}, root.pending);
            delete remaining[bus];
            root.pending = remaining;

            root.writingBus = parseInt(bus);
            writer.command = [root.helperPath, "set", bus, String(value)];
            writer.running = true;
            return;
        }
    }

    Process {
        id: writer
        onExited: {
            reader.exited = false;
            reader.streamFinished = false;
            reader.settled = false;
            reader.command = [root.helperPath, "get", String(root.writingBus)];
            reader.running = true;
        }
    }

    Process {
        id: reader

        property string outputText: ""
        property bool exited: false
        property bool streamFinished: false
        property bool settled: false

        stdout: StdioCollector {
            onStreamFinished: {
                reader.outputText = this.text;
                reader.streamFinished = true;
                root.settleReader();
            }
        }

        // `running` can still read true at the moment onStreamFinished fires --
        // the same exited/streamFinished ordering hazard HomeAssistantConfig.qml
        // guards against -- so pump() must not be re-entered from here directly.
        // Settle on whichever of exited/streamFinished arrives last instead.
        onExited: (code, status) => {
            reader.exited = true;
            root.settleReader();
        }
    }

    function settleReader(): void {
        if (reader.settled || !reader.exited || !reader.streamFinished)
            return;
        reader.settled = true;

        const actual = parseInt(reader.outputText.trim());
        if (!isNaN(actual)) {
            root.displays = root.displays.map(display =>
                display.bus === root.writingBus
                    ? Object.assign({}, display, { value: actual })
                    : display);
        }
        root.writingBus = -1;
        // Anything queued while this write was in flight goes now.
        root.pump();
    }
}
