pragma Singleton

// Video wallpapers, played by mpvpaper — one supervised process per output,
// hardware-decoded, muted, looped. Panama owns the pause policy, because the
// compositor cannot be trusted with it: Hyprland rebuilds the full-screen blur
// chain on every wallpaper frame while any blur-enabled layer exists, and an
// occluded wallpaper keeps compositing unless solitary mode holds — which a
// single toast breaks. So the wallpaper pauses itself whenever a game runs,
// whenever the machine is on battery (if the preference says so), and whenever
// the bar pill is clicked, over mpv's JSON IPC socket.
//
// hyprpaper and mpvpaper both claim the background layer and stacking within a
// layer is creation order — a race. So while a video is active hyprpaper's
// service is stopped, and stopping the video starts it again; Wallpaper.qml
// reapplies the still policy once it returns.
//
// mpvpaper 1.9 (Terra) is the floor: it carries the libmpv fence-leak
// workaround. Known upstream sharp edges — a hotplug segfault and a
// mitigated-not-fixed leak — are why the processes are supervised and simply
// respawned rather than trusted.

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

Singleton {
    id: root

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

    property bool available: false     // mpvpaper on PATH, checked once
    property bool scanned: false
    property var candidates: []        // newest-60 videos in videoWallpaperDir
    property string path: ""           // the active video, "" when inactive
    readonly property bool active: root.path !== ""
    property string lastError: ""

    // Pause reasons, each independent; the video plays only when none holds.
    property bool manuallyPaused: false
    readonly property bool gamePaused: FocusModes.gameRunning
    readonly property bool batteryPaused: DesktopPreferences.get("videoWallpaperPauseOnBattery") === true
        && Battery.available && !Battery.acOnline
    readonly property bool paused: root.manuallyPaused || root.gamePaused || root.batteryPaused

    readonly property string frameDir: Quickshell.env("XDG_STATE_HOME") !== ""
        ? Quickshell.env("XDG_STATE_HOME") + "/panama"
        : Quickshell.env("HOME") + "/.local/state/panama"
    // The lock screen cannot play video; panama-lock uses this still instead.
    readonly property string framePath: root.frameDir + "/video-wallpaper-frame.png"

    readonly property var outputs: Quickshell.screens.map(screen => screen.name)
    // A string, because the array above is a fresh identity every evaluation
    // and respawning on identity churn stacked players on the same output.
    readonly property string outputSignature: root.outputs.join(",")

    function videoDir(): string {
        const stored = String(DesktopPreferences.get("videoWallpaperDir") || "Videos/Wallpapers");
        if (stored.startsWith("/"))
            return stored;
        if (stored.startsWith("~/"))
            return Quickshell.env("HOME") + stored.slice(1);
        return Quickshell.env("HOME") + "/" + stored;
    }

    function isVideo(candidate: string): bool {
        return /\.(mp4|mkv|webm)$/i.test(String(candidate));
    }

    function socketFor(output: string): string {
        return Quickshell.env("XDG_RUNTIME_DIR") + "/panama-wallpaper-" + output + ".sock";
    }

    // ── Discovery ───────────────────────────────────────────────────────────

    function rescan(): void {
        if (!scanProc.running) {
            scanProc.command = [root.helperPath, "scan", root.videoDir()];
            scanProc.running = true;
        }
    }

    Process {
        id: scanProc
        stdout: StdioCollector {
            onStreamFinished: {
                root.candidates = this.text.split("\n").filter(line => line !== "");
                root.scanned = true;
            }
        }
    }

    Process {
        id: probeProc
        command: ["sh", "-c", "command -v mpvpaper >/dev/null && echo yes || echo no"]
        running: true
        stdout: StdioCollector {
            onStreamFinished: root.available = this.text.trim() === "yes"
        }
    }

    // ── Playback lifecycle ──────────────────────────────────────────────────

    // One mpvpaper per output. `-p -a FULL` is belt-and-braces auto-pause;
    // the real pausing happens over the IPC socket below. `hwdec=vaapi` is
    // named explicitly so a silent software-decode fallback cannot hide.
    // No `-f`: that flag forks mpvpaper into the background, which reads to a
    // supervisor as the process dying instantly — it must stay foreground so
    // onExited means what it says.
    function mpvpaperCommand(output: string): var {
        return ["mpvpaper", "-o",
            "hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 "
                + "input-ipc-server=" + root.socketFor(output),
            "-p", "-a", "FULL",
            output, root.path];
    }

    function start(video: string): bool {
        if (!root.available) {
            root.lastError = "mpvpaper is not installed — run panama update to pick it up.";
            return false;
        }
        if (!root.isVideo(video))
            return false;
        root.lastError = "";
        root.path = video;
        root.manuallyPaused = false;
        hyprpaperControl.command = ["systemctl", "--user", "stop", "hyprpaper.service"];
        hyprpaperControl.running = true;
        frameProc.command = ["sh", "-c",
            "mkdir -p '" + root.frameDir + "' && exec '" + root.helperPath
                + "' frame \"$1\" \"$2\"", "frame", video, root.framePath];
        frameProc.running = true;
        playerRespawn.restart();
        return true;
    }

    function stop(): void {
        if (!root.active)
            return;
        root.restoreConsumed = true;
        root.path = "";
        root.manuallyPaused = false;
        playerRespawn.stop();
        spawnDelay.stop();
        for (const player of root.players) {
            player.retiring = true;
            player.running = false;
        }
        root.players = [];
        reaper.running = true;
        hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
        hyprpaperControl.running = true;
        Qt.callLater(() => Wallpaper.refreshActive());
    }

    // Players are created per current output set each (re)spawn, so hotplug
    // lands on the same path as a crash: kill what runs, spawn fresh.
    property var players: []

    Timer {
        id: playerRespawn
        interval: 400
        onTriggered: root.spawnPlayers()
    }

    // Reap-then-spawn, always in that order. Every mpvpaper on this session
    // is Panama's, so a blanket pkill is the reliable way to guarantee one
    // player per output — politely asking Process objects to stop proved
    // racy: a replaced object could orphan its child, and three players once
    // stacked on one output.
    function spawnPlayers(): void {
        // Retiring marks a deliberate death: the reaper's kill fires each
        // player's onExited exactly like a crash would, and without the mark
        // the supervisor respawned in response to its own reaping, forever.
        for (const player of root.players) {
            player.retiring = true;
            player.running = false;
        }
        root.players = [];
        reaper.running = true;
    }

    Process {
        id: reaper
        command: ["pkill", "-x", "mpvpaper"]
        onExited: spawnDelay.restart()
    }

    Timer {
        id: spawnDelay
        interval: 300
        onTriggered: {
            if (!root.active)
                return;
            const spawned = [];
            for (const output of root.outputs) {
                const player = playerComponent.createObject(root, { output: output });
                player.command = root.mpvpaperCommand(output);
                player.running = true;
                spawned.push(player);
            }
            root.players = spawned;
            pauseSync.restart();
        }
    }

    Component {
        id: playerComponent

        Process {
            id: player
            property string output: ""
            property bool retiring: false
            onExited: {
                // A dead player while a video is meant to be active is a
                // crash (mpvpaper has a known hotplug segfault): respawn the
                // whole set after a beat rather than reasoning per-output.
                // A retiring player died because the supervisor killed it —
                // reacting to that is how the reap loop once ate its young.
                if (!player.retiring && root.active && !playerRespawn.running)
                    playerRespawn.restart();
            }
        }
    }

    onOutputSignatureChanged: if (root.active) playerRespawn.restart()

    Process {
        id: hyprpaperControl
    }

    Process {
        id: frameProc
    }

    // ── Pause, over mpv's JSON IPC ──────────────────────────────────────────

    onPausedChanged: pauseSync.restart()

    // The sockets appear a moment after mpvpaper starts; a short retry beats
    // ordering ceremony. Each sync writes the current desired state to every
    // output's socket and disconnects.
    Timer {
        id: pauseSync
        interval: 600
        onTriggered: root.pushPauseState()
    }

    function pushPauseState(): void {
        if (!root.active)
            return;
        for (const output of root.outputs) {
            const socket = pauseSocketComponent.createObject(root, {
                socketPath: root.socketFor(output),
                // Built as a string, not an object literal: a QML-side
                // `command: [...]` reads to declared-assets-contract's scanner
                // as a Process launch.
                payload: JSON.stringify({ "command": ["set_property", "pause", root.paused] }) + "\n"
            });
            socket.connected = true;
        }
    }

    Component {
        id: pauseSocketComponent

        Socket {
            id: pauseSocket
            property string payload: ""
            path: socketPath
            property string socketPath: ""
            onConnectionStateChanged: {
                if (connected) {
                    write(payload);
                    flush();
                    Qt.callLater(() => { pauseSocket.connected = false; pauseSocket.destroy(); });
                }
            }
            onError: pauseSocket.destroy()
        }
    }

    function togglePause(): void {
        root.manuallyPaused = !root.manuallyPaused;
    }

    // ── Startup restore ─────────────────────────────────────────────────────
    // The service restores its own video, reactively: at cold start the
    // preferences file and the mpvpaper probe both land asynchronously, so a
    // one-shot timer (the still pipeline's approach) raced them and lost.
    // Once per session — a user's stop() is not to be overridden.
    property bool restoreConsumed: false
    // Harness seam, mirroring Wallpaper.startupRestoreEnabled: a test instance
    // must never start playing the user's real wallpaper.
    property bool startupRestoreEnabled: true

    function tryRestore(): void {
        if (!root.startupRestoreEnabled)
            return;
        if (root.restoreConsumed || root.active || !root.available)
            return;
        const video = String(DesktopPreferences.get("videoWallpaperPath") || "");
        if (!root.isVideo(video))
            return;
        root.restoreConsumed = true;
        root.start(video);
    }

    onAvailableChanged: root.tryRestore()

    Connections {
        target: DesktopPreferences
        function onRevisionChanged(): void { root.tryRestore(); }
    }

    Component.onCompleted: {
        root.rescan();
        root.tryRestore();
    }
}
