pragma Singleton
pragma ComponentBehavior: Bound

import qs.modules.common
import Quickshell
import Quickshell.Io
import QtQuick

/**
 * Speech — TTS egress for the shell (the Read Aloud seam, TASK-18).
 *
 * Souveraine owns the who→voice mapping: VoiceConfig in the consciousness
 * config carries tts_url and voice_id, served live at /v1/config. This
 * service reads that mapping on every speak() and posts the text to
 * VibeVoice's /audio/speech (JSON {input, voice, model}, mp3 back — the
 * same contract core/voice/client.rs speaks), then plays the result. The
 * shell picks no voice of its own; souveraine decides how she sounds.
 *
 * Config.options.speech.tts.enable is the user's kill switch (settings →
 * Speech); Config.options.speech.tts.endpoint, when set, overrides the
 * server-mapped tts_url.
 *
 * Prefetch: the latest assistant reply is synthesized into the single tmpfs
 * file in the background the moment it finishes streaming, so tapping Speak
 * is near-instant when the audio is already ready. Prefetch NEVER auto-plays
 * — the user always initiates playback. A prefetch that fails is not retried
 * (the manual Speak path retries on its own when tapped). Only the latest
 * reply is kept; a new/edited reply invalidates the cached file because
 * readiness is keyed on the exact text.
 */
Singleton {
    id: root

    readonly property bool enabled: Config.options?.speech?.tts?.enable ?? false
    property bool speaking: synthProc.running || playProc.running
    property string lastError: ""

    property string _pendingText: ""
    // Resolved endpoint + voice from the last /v1/config lookup, kept so a
    // transient synth failure can be retried without re-resolving.
    property string _synthUrl: ""
    property string _synthVoice: ""
    // Retry cap: the VibeVoice server can intermittently return 500 (concurrency
    // on its shared model — now server-locked, but a collision still costs one
    // failed request) or time out on a slow synth. One retry recovers the common
    // case; a second failure surfaces a human message.
    property int _synthAttempts: 0
    readonly property int _synthMaxAttempts: 2
    readonly property string _outFile: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/souveraine-speech.mp3"

    // What the file on disk currently holds (the exact text it was synthesized
    // from), or "" if nothing valid is cached. Keyed on text so a new or edited
    // reply naturally misses → Speak synthesizes fresh. Set only on a successful
    // synth; cleared by stop()/speak()/prefetch() of different text.
    property string _readyText: ""

    // speak — synthesize and play `text` in the agent's voice. A new call
    // replaces any in-flight synthesis or playback. If `text` is already
    // cached on disk (from a prefetch), skip synthesis and play instantly.
    // Check the cache BEFORE stopping — a prefetch that just completed should
    // be honored, not killed and re-requested.
    function speak(text) {
        const t = String(text ?? "").trim();
        if (t.length === 0 || !root.enabled) return;
        root.lastError = "";
        // Prefetch hit: audio for exactly this text is already on disk — play
        // it straight away, no synth round-trip. Don't call stop() here;
        // if a prefetch is still running for a *different* message, stop()
        // would kill it — but our text matches, so we know the file is ours.
        if (root._readyText === t) {
            stop();
            root._pendingText = t;
            root._play();
            return;
        }
        // No cache hit — stop anything in-flight and synthesize fresh.
        stop();
        root._pendingText = t;
        root._synthAttempts = 0;
        root._readyText = "";
        // If the endpoint+voice are already resolved, skip the /v1/config
        // round-trip and go straight to synthesis.
        if (root._synthUrl.length > 0 && root._synthVoice.length > 0) {
            root._fireSynth(false /*interactive*/);
        } else {
            voiceLookup.running = true;
        }
    }

    // prefetch — synthesize `text` into the cache file in the background so a
    // later speak() of the same text plays instantly. Does NOT play. A prefetch
    // failure is silent and not retried; the eventual manual speak() will
    // synthesize (with its own retry) if the text still isn't ready.
    function prefetch(text) {
        const t = String(text ?? "").trim();
        if (t.length === 0 || !root.enabled) return;
        // Already have exactly this text ready — don't re-request it.
        if (root._readyText === t) return;
        // Don't let a prefetch clobber a speak() the user just initiated.
        if (root.speaking && root._pendingText !== t) return;
        root._pendingText = t;
        root._synthAttempts = 0;
        // Mark this as a background prefetch so voiceLookup chains into
        // _fireSynth(prefetch=true) instead of treating it as interactive.
        synthProc._prefetch = true;
        // If the endpoint+voice are already resolved, go straight to synth;
        // otherwise resolve via /v1/config first.
        if (root._synthUrl.length > 0 && root._synthVoice.length > 0) {
            root._fireSynth(true /*prefetch*/);
        } else {
            voiceLookup.running = true;
        }
    }

    function stop() {
        voiceLookup.running = false;
        synthProc.running = false;
        playProc.running = false;
        retryTimer.running = false;
    }

    // Play whatever is in the cache file.
    function _play() {
        playProc.running = true;
    }

    // Fire (or re-fire) the synth request with the already-resolved url+voice.
    // `prefetch` marks the result as a background fill (no playback, no retry,
    // populate _readyText on success) vs. an interactive speak().
    function _fireSynth(prefetch) {
        synthProc._prefetch = prefetch;
        synthProc.command = [
            "curl", "-sf", "--max-time", "180",
            "-X", "POST", "-H", "Content-Type: application/json",
            "-d", JSON.stringify({
                input: root._pendingText,
                voice: root._synthVoice,
                model: "vibevoice-v1"
            }),
            "-o", root._outFile,
            `${root._synthUrl.replace(/\/$/, "")}/audio/speech`
        ];
        synthProc.running = true;
    }

    // Delay before a retry — short, just long enough for the server to clear.
    Timer {
        id: retryTimer
        interval: 750
        repeat: false
        onTriggered: root._fireSynth(false /*interactive speak retries only*/)
    }

    // ── who → voice, from souveraine ─────────────────────────────────────
    Process {
        id: voiceLookup
        command: ["curl", "-sf", "--max-time", "3", `${Souveraine.serverBase}/v1/config`]
        stdout: StdioCollector {
            onStreamFinished: {
                let ttsUrl = Config.options?.speech?.tts?.endpoint ?? "";
                let voice = "";
                try {
                    const cfg = JSON.parse(text);
                    voice = cfg?.voice?.voice_id ?? "";
                    if (ttsUrl.length === 0)
                        ttsUrl = cfg?.voice?.tts_url ?? "";
                } catch (e) {
                    console.log("[Speech] could not parse /v1/config:", e);
                }
                if (ttsUrl.length === 0) {
                    root.lastError = "no TTS endpoint (server unmapped, shell override empty)";
                    console.log("[Speech]", root.lastError);
                    return;
                }
                root._synthUrl = ttsUrl;
                root._synthVoice = voice;
                // If speak() was the caller, synth+play; if prefetch() was, the
                // flag is already on synthProc from _fireSynth — but voiceLookup
                // can be the first hop of a prefetch, so default to interactive.
                root._fireSynth(synthProc._prefetch ?? false);
            }
        }
        onExited: (exitCode) => {
            // Exit 15 = SIGTERM from stop(); not a real failure — suppress.
            if (exitCode !== 0 && exitCode !== 15) {
                root.lastError = "souveraine server unreachable for voice mapping";
                console.log("[Speech]", root.lastError);
            }
        }
    }

    // ── synthesis ────────────────────────────────────────────────────────
    Process {
        id: synthProc
        // True when this synth is a background prefetch (no playback, no retry).
        property bool _prefetch: false
        onExited: (exitCode) => {
            if (exitCode === 0) {
                // Success: the file now holds _pendingText.
                root._readyText = root._pendingText;
                if (synthProc._prefetch) {
                    // Prefetch only fills the cache; never auto-plays.
                    synthProc._prefetch = false;
                    return;
                }
                root._play();
                return;
            }
            // Prefetch failures are silent and not retried — per design, we
            // don't burn a second request on background fill. The manual
            // speak() path will synthesize (and retry) when actually tapped.
            if (synthProc._prefetch) {
                synthProc._prefetch = false;
                console.log("[Speech] prefetch failed (curl exit " + exitCode + ") — will synth on demand when spoken");
                return;
            }
            // Exit 15 = SIGTERM — stop() killed this synth (user tapped stop,
            // or a new speak() replaced it). This is always intentional, never
            // an error. Suppress silently.
            if (exitCode === 15) {
                return;
            }
            // Transient failures: exit 22 = HTTP ≥400 (the server's intermittent
            // 500 under model concurrency), exit 28 = timeout (slow synth).
            // Retry once; a collision or a slow first attempt usually clears.
            const transient = (exitCode === 22 || exitCode === 28);
            root._synthAttempts += 1;
            if (transient && root._synthAttempts < root._synthMaxAttempts) {
                console.log(`[Speech] synth exit ${exitCode}, retry ${root._synthAttempts}/${root._synthMaxAttempts - 1}`);
                retryTimer.running = true;
                return;
            }
            if (exitCode === 28) {
                root.lastError = root._synthAttempts > 1
                    ? "voice synthesis timed out twice — the server may be busy or the reply too long"
                    : "voice synthesis timed out";
            } else if (exitCode === 22) {
                root.lastError = root._synthAttempts > 1
                    ? "voice synthesis failed twice — the TTS server returned an error (may be busy)"
                    : "voice synthesis failed (server error)";
            } else {
                root.lastError = `voice synthesis failed (curl exit ${exitCode})`;
            }
            console.log("[Speech]", root.lastError);
        }
    }

    // ── playback ─────────────────────────────────────────────────────────
    Process {
        id: playProc
        command: ["sh", "-c",
            `mpv --no-video --really-quiet '${root._outFile}' 2>/dev/null ` +
            `|| ffplay -nodisp -autoexit -loglevel quiet '${root._outFile}'`]
        onExited: (exitCode) => {
            // Exit 15 = SIGTERM from stop(); not a real failure — suppress.
            if (exitCode !== 0 && exitCode !== 15) {
                root.lastError = `audio playback failed (exit ${exitCode}) — mpv/ffplay present?`;
                console.log("[Speech]", root.lastError);
            }
        }
    }
}
