pragma Singleton
pragma ComponentBehavior: Bound

import qs.modules.common.functions as CF
import qs.modules.common
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick

/**
 * Souveraine — the substrate singleton for every shell module.
 *
 * This is the one connection to the Souveraine server. The chat sidebar,
 * presence widget, cockpit pane, agent manager and settings module all hang
 * off this service; none of them open their own transport. Ai.qml is the
 * ii-compat adapter over this for the existing sidebar UI.
 *
 * Responsibilities:
 *  - agent inventory (GET /v1/agents)
 *  - conversation lifecycle (create and server-derived resume)
 *  - the SSE turn stream — raw events re-emitted via streamEvent(var)
 *  - the backchannel: cancelTurn() and interject(text)
 *  - the desktop sensorium: every send carries ambient context (active
 *    window, open apps, cursor position) so she perceives the room she is
 *    being spoken to in. Extension point for device sensors (SouveraineOS).
 */
Singleton {
    id: root

    property string serverBase: Config.options?.ai?.souveraineUrl ?? "http://127.0.0.1:8484"
    property bool serverUp: false
    // Ambient perception on by default; ai.ambient=false in ii config disables.
    property bool ambientEnabled: Config.options?.ai?.ambient ?? true
    // Start `souveraine server` ourselves when it isn't running. The surface
    // is the OS frontend — opening it means summoning her, not staring at a
    // connection error. ai.souveraineAutostart=false disables; a manual
    // start affordance can call startServer() directly.
    property bool autostartEnabled: Config.options?.ai?.souveraineAutostart ?? true
    property string serverBin: Config.options?.ai?.souveraineBin ?? "souveraine"
    property bool _autostartTried: false

    // id -> { name, description }
    property var agents: ({})
    property var agentList: Object.keys(agents)
    property string currentAgentId: ""
    property string conversationId: ""
    property bool turnActive: false

    /* Raw wire events (message_type-tagged objects from the SSE stream). */
    signal streamEvent(var event)
    /* Stream closed (process exit). exitCode 0 = clean. */
    signal streamClosed(int exitCode)
    signal agentsRefreshed()
    signal serverUnreachable()
    // Emitted after a server-owned conversation has been selected and its
    // persisted transcript loaded for the active surface.
    signal conversationResumed(string agentId, string conversationId, var messages)
    // Emitted when a send is blocked by step-up auth. The UI should call
    // StepUpAuth.requestAuth("send", callback) and retry on success.
    signal stepUpRequired(string actionFamily, string queuedText)

    // ── Agent inventory ──────────────────────────────────────────────────
    Process {
        id: getAgents
        running: true
        command: ["curl", "-sf", "--max-time", "3", `${root.serverBase}/v1/agents`]
        stdout: StdioCollector {
            onStreamFinished: {
                if (text.length === 0) return;
                try {
                    const list = JSON.parse(text);
                    const map = {};
                    list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "" }; });
                    root.agents = map;
                    root.agentList = Object.keys(map);
                    root.serverUp = true;
                    if (!root.agents[root.currentAgentId] && root.agentList.length > 0) {
                        root.currentAgentId = root.agentList[0];
                    }
                    root.agentsRefreshed();
                } catch (e) {
                    console.log("[Souveraine] Could not parse agent list:", e);
                }
            }
        }
        onExited: (exitCode) => {
            if (exitCode !== 0) {
                root.serverUp = false;
                if (root.autostartEnabled && !root._autostartTried) {
                    root.startServer();
                } else {
                    root.serverUnreachable();
                }
            }
        }
    }

    function refreshAgents() {
        getAgents.running = true;
    }

    // ── Server autostart ─────────────────────────────────────────────────
    // systemd user unit first (survives shell restarts, journald logging);
    // bare nohup fallback for systems without it. One attempt per shell
    // session — a broken install shouldn't spawn-loop.
    Process {
        id: serverStarter
        command: ["bash", "-c",
            `if command -v systemctl >/dev/null && systemctl --user list-unit-files souveraine.service &>/dev/null; then
                systemctl --user start souveraine.service
            else
                nohup ${root.serverBin} server >/dev/null 2>&1 &
            fi`]
        onExited: {
            serverRetryTimer.start();
        }
    }

    Timer {
        id: serverRetryTimer
        interval: 2500
        repeat: false
        onTriggered: root.refreshAgents()
    }

    function startServer() {
        if (root._autostartTried) return;
        root._autostartTried = true;
        console.log("[Souveraine] server not reachable — starting it");
        serverStarter.running = true;
    }

    function selectAgent(agentId) {
        if (!root.agents[agentId]) return false;
        // A live turn belongs to the current conversation. Switching beneath
        // it would render one agent's response in another agent's surface.
        if (root.turnActive) return false;
        root.currentAgentId = agentId;
        root.conversationId = "";
        return true;
    }

    function newConversation() {
        root.conversationId = "";
    }

    // ── Server-derived resume ───────────────────────────────────────────
    // The GUI keeps no per-agent conversation map. The server is the source
    // of truth: it persists conversations under each agent and this query
    // hydrates them after a server restart before returning the latest one.
    Process {
        id: listConversations
        property string agentId: ""
        stdout: StdioCollector {
            onStreamFinished: {
                let conversations = [];
                try {
                    conversations = text.length > 0 ? JSON.parse(text) : [];
                } catch (e) {
                    console.log("[Souveraine] Could not parse conversation list:", e);
                }
                if (listConversations.agentId !== root.currentAgentId) return;
                if (conversations.length === 0) {
                    root.conversationId = "";
                    root.conversationResumed(root.currentAgentId, "", []);
                    return;
                }
                root._loadConversation(listConversations.agentId, conversations[0].id);
            }
        }
        onExited: exitCode => {
            if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) {
                root.conversationId = "";
                root.conversationResumed(root.currentAgentId, "", []);
            }
        }
    }

    Process {
        id: loadConversation
        property string agentId: ""
        property string requestedConversationId: ""
        stdout: StdioCollector {
            onStreamFinished: {
                try {
                    const messages = text.length > 0 ? JSON.parse(text) : [];
                    if (loadConversation.agentId !== root.currentAgentId) return;
                    root.conversationId = loadConversation.requestedConversationId;
                    root.conversationResumed(root.currentAgentId, root.conversationId, messages);
                } catch (e) {
                    console.log("[Souveraine] Could not parse conversation transcript:", e);
                    root.conversationResumed(root.currentAgentId, "", []);
                }
            }
        }
        onExited: exitCode => {
            if (exitCode !== 0 && loadConversation.agentId === root.currentAgentId) {
                root.conversationId = "";
                root.conversationResumed(root.currentAgentId, "", []);
            }
        }
    }

    function resumeLatestConversation() {
        if (!root.serverUp || root.currentAgentId.length === 0 || root.turnActive) return false;
        listConversations.agentId = root.currentAgentId;
        listConversations.command = [
            "curl", "-sf", "--max-time", "5",
            `${root.serverBase}/v1/conversations?agent_id=${encodeURIComponent(root.currentAgentId)}`
        ];
        listConversations.running = true;
        return true;
    }

    function _loadConversation(agentId, conversationId) {
        loadConversation.agentId = agentId;
        loadConversation.requestedConversationId = conversationId;
        loadConversation.command = ["bash", "-c",
            root._tokenReadLine(agentId)
            + `curl -sf --max-time 10 "${root.serverBase}/v1/conversations/${conversationId}/messages"`
            + ` -H "Authorization: Bearer $TOKEN"`
        ];
        loadConversation.running = true;
    }

    // ── Ambient sensorium ────────────────────────────────────────────────
    // What the desktop feels like at the moment of speaking. Cheap,
    // synchronous reads here; the cursor needs a hyprctl round-trip and is
    // collected in the send chain. Device sensors (SouveraineOS positional
    // data) extend collectAmbient().
    property string _cursorPos: ""

    function collectAmbient() {
        if (!root.ambientEnabled) return "";
        const lines = [];
        const active = ToplevelManager.activeToplevel;
        if (active) {
            lines.push(`active window: ${active.appId ?? "?"} — "${active.title ?? ""}"`);
        }
        const tops = ToplevelManager.toplevels?.values ?? [];
        if (tops.length > 0) {
            const apps = tops.map(t => t.appId).filter(Boolean);
            const counts = {};
            apps.forEach(a => counts[a] = (counts[a] ?? 0) + 1);
            const summary = Object.entries(counts)
                .map(([app, n]) => n > 1 ? `${app} (${n})` : app)
                .join(", ");
            lines.push(`open: ${summary}`);
        }
        if (root._cursorPos.length > 0) {
            lines.push(`cursor: ${root._cursorPos}`);
        }
        return lines.join("\n");
    }

    // Compositor-specific cursor read. hyprctl on Hyprland, kdotool on KDE;
    // anything else just skips the cursor line — ambient degrades gracefully,
    // it never blocks the send.
    Process {
        id: cursorProc
        command: ["bash", "-c",
            `if command -v hyprctl >/dev/null; then hyprctl cursorpos;
             elif command -v kdotool >/dev/null; then kdotool getmouselocation 2>/dev/null;
             fi`]
        stdout: StdioCollector {
            onStreamFinished: {
                root._cursorPos = text.trim();
            }
        }
        onExited: {
            root._ensureConversationThenRequest();
        }
    }

    // ── Send chain: cursor → conversation → stream ───────────────────────
    property string _queuedText: ""

    /* Send a user message with ambient context. Returns false if the
       server is down or no agent is selected. Returns "step-up" if
       step-up auth is required but no valid grant exists — the caller
       should trigger StepUpAuth.requestAuth("send") and retry. */
    function send(text) {
        if (!root.serverUp || root.currentAgentId.length === 0) return false;
        if (text.length === 0) return false;
        // Step-up gate: if enabled and no valid send grant exists, block
        // the send and emit a signal so the UI can trigger auth + retry.
        // Break-glass grants bypass normal step-up — they are one-time,
        // short-lived, and journaled.
        if (Config.options?.lock?.stepUp?.enabled
            && typeof StepUpAuth !== "undefined"
            && !StepUpAuth.isGranted("send")
            && !StepUpAuth.isBreakGlass("send")) {
            root.stepUpRequired("send", text);
            return "step-up";
        }
        root._queuedText = text;
        if (root.ambientEnabled) {
            cursorProc.running = true; // chain continues in onExited
        } else {
            root._ensureConversationThenRequest();
        }
        return true;
    }

    Process {
        id: createConversation
        stdout: StdioCollector {
            onStreamFinished: {
                try {
                    const conv = JSON.parse(text);
                    root.conversationId = conv.id;
                    root._makeRequest();
                } catch (e) {
                    console.log("[Souveraine] conversation create failed:", text);
                    root.streamClosed(1);
                }
            }
        }
    }

    function _ensureConversationThenRequest() {
        if (root.conversationId.length > 0) {
            root._makeRequest();
            return;
        }
        createConversation.command = [
            "curl", "-sf", "-X", "POST",
            `${root.serverBase}/v1/conversations`,
            "-H", "Content-Type: application/json",
            "--data", JSON.stringify({ "agent_id": root.currentAgentId })
        ];
        createConversation.running = true;
    }

    property string requestScriptFilePath: "/tmp/quickshell/ai/souveraine-request.sh"

    FileView {
        id: requesterScriptFile
    }

    function _tokenReadLine(agentId) {
        // Bearer token read at request time so rotation works.
        return `TOKEN=$(cat "$HOME/.souveraine/server/agents/${agentId}/api_token" 2>/dev/null)\n`;
    }

    function _makeRequest() {
        const data = {
            "messages": [{ "role": "user", "content": root._queuedText }],
            "stream": true
        };
        const ambient = root.collectAmbient();
        if (ambient.length > 0) data["ambient"] = ambient;
        root._queuedText = "";

        const scriptContent = "#!/usr/bin/env bash\n"
            + root._tokenReadLine(root.currentAgentId)
            + `curl --no-buffer -sS -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/messages"`
            + ` -H 'Content-Type: application/json'`
            + ` -H "Authorization: Bearer $TOKEN"`
            + ` --data '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
            + "\n";

        const shellScriptPath = CF.FileUtils.trimFileProtocol(root.requestScriptFilePath);
        requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath);
        requesterScriptFile.setText(scriptContent);
        requester.command = ["bash", shellScriptPath];
        root.turnActive = true;
        requester.running = true;
    }

    Process {
        id: requester
        stdout: SplitParser {
            onRead: data => {
                if (data.length === 0 || !data.startsWith("data:")) return;
                let event;
                try {
                    event = JSON.parse(data.slice(5).trim());
                } catch (e) {
                    console.log("[Souveraine] Unparseable SSE line:", data);
                    return;
                }
                root.streamEvent(event);
            }
        }
        onExited: (exitCode, exitStatus) => {
            root.turnActive = false;
            root.streamClosed(exitCode);
        }
    }

    // ── Backchannel ──────────────────────────────────────────────────────
    Process {
        id: backchannelProc
        property string script: ""
        command: ["bash", "-c", script]
    }

    function cancelTurn() {
        if (root.conversationId.length === 0) return;
        backchannelProc.script = root._tokenReadLine(root.currentAgentId)
            + `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/cancel"`
            + ` -H "Authorization: Bearer $TOKEN"`;
        backchannelProc.running = true;
    }

    function interject(text) {
        if (root.conversationId.length === 0 || text.length === 0) return;
        const body = JSON.stringify({ "text": text });
        backchannelProc.script = root._tokenReadLine(root.currentAgentId)
            + `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/interject"`
            + ` -H 'Content-Type: application/json'`
            + ` -H "Authorization: Bearer $TOKEN"`
            + ` --data '${CF.StringUtils.shellSingleQuoteEscape(body)}'`;
        backchannelProc.running = true;
    }
}
