// Shell side of the souveraine-sessiond handoff protocol.
//
// sessiond takes ext-session-lock before the shell exists; this bridge is
// how the shell (a) announces itself and takes the lock over, (b) keeps the
// heartbeat connection open so sessiond can retake the lock the moment the
// shell dies, and (c) confirms the compositor-acked lock (locked_ack).
//
// The session is never unlocked during the handoff: sessiond abandons its
// lock (connection drop) and misc:allow_session_lock_restore lets our
// WlSessionLock inherit the locked session. See
// souveraine/src/sessiond/protocol.rs — that file is the contract.
//
// No sessiond on the socket (laptop, or bring-up) = everything no-ops and
// the legacy launchOnStartup path decides alone.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.modules.common.functions

Singleton {
    id: root

    // Only shell.qml calls claimAuthority(). Importing this singleton from a
    // utility window must never create a session-authority connection.
    property bool authorityScope: false

    // Registered = shell_ready was answered ok on the CURRENT connection.
    property bool registered: false
    // Latched copy of `registered` taken when the connection drops. The
    // disconnect branch clears `registered` before the reconnect branch runs,
    // so reconnect cannot read it directly to decide whether to re-register.
    property bool wasRegistered: false
    // sessiond held the session lock when we registered; we owe it a lock
    // and a locked_ack.
    property bool oweLock: false
    property bool ackSent: false
    property var pendingReady: null // callback awaiting the shell_ready response

    function claimAuthority() {
        root.authorityScope = true
    }

    function load() {
        if (!root.authorityScope)
            console.log("[sessiond-bridge] inactive outside authority scope")
    }

    // Announce the shell. cb(mustLock) fires exactly once: mustLock true
    // means sessiond was holding and the session IS locked — the shell must
    // raise its own lock surface immediately.
    function shellReady(cb) {
        if (!root.authorityScope) {
            console.log("[sessiond-bridge] shell_ready refused outside authority scope")
            cb(false)
            return
        }
        if (!sock.connected) {
            cb(false);
            return;
        }
        if (root.pendingReady) {
            // Only one handshake in flight; late duplicate answers false.
            cb(false);
            return;
        }
        root.pendingReady = cb;
        readyTimeout.restart();
        sock.write(JSON.stringify({ op: "shell_ready" }) + "\n");
        sock.flush();
    }

    function sendLockedAck() {
        if (!sock.connected || !root.registered || root.ackSent) return;
        root.ackSent = true;
        sock.write(JSON.stringify({ op: "locked_ack" }) + "\n");
        sock.flush();
        console.log("[sessiond-bridge] locked_ack sent");
    }

    Timer {
        id: readyTimeout
        interval: 3000
        repeat: false
        onTriggered: {
            if (root.pendingReady) {
                console.log("[sessiond-bridge] shell_ready timed out — proceeding without sessiond");
                const cb = root.pendingReady;
                root.pendingReady = null;
                cb(false);
            }
        }
    }

    // Reconnect: sessiond may restart (upgrade) or start late. While
    // connected this timer is idle; the Socket does not retry by itself.
    Timer {
        id: reconnect
        interval: 5000
        repeat: true
        running: root.authorityScope && !sock.connected
        onTriggered: sock.connected = true
    }

    Socket {
        id: sock
        path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
        connected: root.authorityScope

        // `connectionStateChanged` is the real Signal on Quickshell.Io.Socket;
        // the `onSocketConnected`/`onSocketDisconnected` handler-slots aren't
        // reliably attachable across quickshell builds, so branch on
        // `connected` here. Re-register on reconnect if we were registered
        // before (sessiond restarted underneath us).
        onConnectionStateChanged: {
            if (sock.connected) {
                console.log("[sessiond-bridge] connected");
                const wasRegistered = root.wasRegistered;
                root.registered = false;
                root.wasRegistered = false;
                root.ackSent = false;
                if (wasRegistered) {
                    root.shellReady(function(mustLock) {
                        if (mustLock) {
                            // sessiond locked while we were disconnected (it
                            // treats our EOF as shell death). Mirror it.
                            GlobalStates.screenLocked = true;
                        }
                    });
                }
            } else {
                console.log("[sessiond-bridge] disconnected");
                root.wasRegistered = root.registered;
                root.registered = false;
                root.pendingReady = null;
            }
        }

        parser: SplitParser {
            splitMarker: "\n"
            onRead: message => {
                let reply;
                try {
                    reply = JSON.parse(message);
                } catch (e) {
                    console.log("[sessiond-bridge] unparseable reply: " + message);
                    return;
                }
                // Not every line is a reply. The authority pushes directives
                // down this connection: it owns the decision, the shell owns
                // the surface. A blank that needs a lock first arrives here
                // (LOCK-DPMS-LESSONS §1 — lock, then off), and the daemon is
                // holding the panel dark until this is answered.
                if (reply.directive !== undefined) {
                    root.handleDirective(reply);
                    return;
                }
                if (root.pendingReady) {
                    // The only request we await a response for.
                    const cb = root.pendingReady;
                    root.pendingReady = null;
                    readyTimeout.stop();
                    if (reply.ok) {
                        root.registered = true;
                        root.oweLock = reply.must_lock === true;
                        console.log("[sessiond-bridge] registered, must_lock=" + root.oweLock);
                        cb(root.oweLock);
                    } else {
                        console.log("[sessiond-bridge] shell_ready refused: " + reply.reason);
                        cb(false);
                    }
                }
            }
        }
    }

    // Carry out an authority directive. The shell is the executor here, not a
    // peer deciding whether it agrees: sessiond is the session authority and
    // it has already withheld the panel waiting for this.
    //
    // Unknown directives are LOUD. A newer daemon asking for something this
    // shell cannot do is a real divergence, and silently dropping it would
    // leave the daemon waiting out its ack budget and then blanking unlocked.
    function handleDirective(msg) {
        if (msg.directive === "lock") {
            console.log("[sessiond-bridge] authority directive: lock ("
                + (msg.why || "no reason given") + ")");
            Session.lock();
            return;
        }
        console.error("[sessiond-bridge] UNKNOWN authority directive: "
            + JSON.stringify(msg)
            + " — this shell is older than the daemon driving it");
    }

    // The compositor acknowledged OUR lock surface — tell sessiond the
    // handoff is complete. Gated on secure, not the request, per doctrine.
    Connections {
        target: GlobalStates
        function onScreenLockSecureChanged() {
            if (GlobalStates.screenLockSecure && root.oweLock && !root.ackSent) {
                root.sendLockedAck();
            }
        }
    }
}
