pragma Singleton

// ─────────────────────────────────────────────────────────────────────────────
// Screenshot / screen-recording engine.
//
// This is the whole non-visual half of the GNOME 42 screenshot experience:
// freeze the screen, hand the frozen frame to the picker UI, then run grim or
// wf-recorder against whatever region the user chose.
//
// The UI (modules/capture) only ever reads state from here and calls the
// shoot*/record* functions -- it never spawns a process itself.
//
// Verified against the installed tools on 2026-08-17:
//   grim 1.x        -g "<X>,<Y> <W>x<H>"   <- SPACE before WxH, not a comma.
//                                            "0,0,100x100" is rejected with
//                                            "invalid geometry".
//                   -s <factor>            output image scale
//                   -c                     include the cursor
//   wf-recorder     -g "<X>,<Y> <W>x<H>"   same format as grim
//                   no cursor toggle in this build; the pointer is always
//                   recorded, so "Show Pointer" only affects screenshots.
//   notify-send     -A NAME=Label implies --wait and prints NAME on stdout,
//                                            which is how the notification
//                                            actions below are dispatched.
//   satty 0.22      --filename / --output-filename / --early-exit
// ─────────────────────────────────────────────────────────────────────────────

import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
import qs.config
import qs.services

Singleton {
    id: root

    // ── Picker state (bound by modules/capture) ─────────────────────────────
    // "screen" | "window" | "selection"
    property string mode: "screen"
    // Capture action. The booleans stay explicit because the existing picker
    // reads them directly, while the setters guarantee only one special mode
    // can be active at a time.
    property bool recordMode: false
    property bool intelligenceMode: false
    // GNOME's "Show Pointer" checkbox. Screenshots only -- see the note above.
    property bool showPointer: false

    // file:// URL of the frozen frame the overlay paints as its backdrop, or
    // "" while the grab is still in flight / after it failed.
    readonly property string freezeUrl: root._freezePath === "" ? "" : "file://" + root._freezePath

    // Top-left of the captured layout in Hyprland's global coordinate space.
    // Everything the picker reports back is layout-global; the overlay
    // subtracts these to get window-local pixels.
    property real originX: 0
    property real originY: 0

    // Windows on the focused workspace, newest-focused first, as
    // { address, class, title, x, y, w, h } in layout-global logical pixels.
    property var windows: []

    // ── Recording state (read by the bar / recording indicator) ─────────────
    readonly property bool recording: recProc.running
    property int recordingSeconds: 0
    property string recordingPath: ""

    // ── Coordinate space ────────────────────────────────────────────────────
    // `hyprctl -j clients` reports `at`/`size` in Hyprland's *logical* layout
    // coordinates (the 3000x2000 space on this 1.5x display), and grim's and
    // wf-recorder's -g flags consume the same logical space -- so no scaling is
    // needed anywhere and this stays 1.0. It exists because that is the one
    // thing here that could not be verified without a live Hyprland session: if
    // window outlines land at 2/3 size, set it to 1.5 and the whole pipeline
    // (outlines and grim geometry alike) corrects together.
    property real coordinateScale: 1.0

    // ── Paths ───────────────────────────────────────────────────────────────
    readonly property string _home: Quickshell.env("HOME") || "/home/gib"
    readonly property string shotDir: root._home + "/" + Settings.screenshotDir
    readonly property string recDir: root._home + "/" + Settings.recordingDir

    property string _freezePath: ""
    property string _prevFreezePath: ""

    function _stamp(): string {
        return Qt.formatDateTime(new Date(), "yyyy-MM-dd HH-mm-ss");
    }

    // grim/wf-recorder geometry string, or "" for "the whole thing".
    function _geom(x: real, y: real, w: real, h: real): string {
        const s = root.coordinateScale;
        return Math.round(x * s) + "," + Math.round(y * s) + " " + Math.round(w * s) + "x" + Math.round(h * s);
    }

    // ── Public API (driven by shell.qml's IpcHandler) ───────────────────────

    // Open the GNOME-style picker. The frozen frame is grabbed *before* the
    // overlay is shown, otherwise the overlay would appear in its own backdrop.
    function open(): void {
        if (root.recording) {
            // Print while recording = stop, matching the way GNOME's indicator
            // behaves. The lead binds this to the same key.
            root.stopRecording();
            return;
        }
        if (ShellState.captureOpen) {
            // Print again while the picker is up dismisses it. Re-grabbing here
            // would freeze a screen with the picker already in it.
            root.close();
            return;
        }
        if (ScreenIntelligence.visible)
            ScreenIntelligence.close();
        root.refreshWindows();
        root._grabFreeze();
    }

    function openIntelligence(): void {
        root.mode = "selection";
        root.selectIntelligence();
        root.open();
    }

    function selectScreenshot(): void {
        root.recordMode = false;
        root.intelligenceMode = false;
    }

    function selectRecording(): void {
        root.recordMode = true;
        root.intelligenceMode = false;
    }

    function selectIntelligence(): void {
        root.recordMode = false;
        root.intelligenceMode = true;
    }

    function close(): void {
        ShellState.close();
        root._dropFreeze();
    }

    // Immediate whole-screen screenshot, no UI. (Shift+Print)
    function screenNow(): void {
        root.shootRegion("");
    }

    // Immediate active-window screenshot, no UI. (Alt+Print)
    function windowNow(): void {
        activeWindowProc.running = false;
        activeWindowProc.running = true;
    }

    function stopRecording(): void {
        if (!recProc.running)
            return;
        // SIGINT, never SIGKILL: wf-recorder needs to flush and write the
        // container trailer or the file is unplayable.
        recProc.signal(2);
    }

    // ── Execution ───────────────────────────────────────────────────────────

    // geom: "" for the full output, otherwise a grim geometry string.
    function shootRegion(geom: string): void {
        // Capital "From" matches the files GNOME already left in
        // ~/Pictures/Screenshots, so old and new shots sort together.
        const name = "Screenshot From " + root._stamp() + ".png";
        const args = ["sh", "-c", root._shotScript, "qs-capture", root.shotDir, name];
        if (root.showPointer)
            args.push("-c");
        if (geom !== "")
            args.push("-g", geom);
        else if (root._outputName !== "")
            args.push("-o", root._outputName);
        Quickshell.execDetached(args);
    }

    function recordRegion(geom: string): void {
        if (recProc.running)
            return;
        // .mp4 rather than GNOME's .webm because Settings.recorderArgs encodes
        // h264 on the AMD VAAPI device.
        const name = "Screencast From " + root._stamp() + ".mp4";
        root.recordingPath = root.recDir + "/" + name;

        // mkdir + exec so the PID we later SIGINT is wf-recorder itself and not
        // the shell wrapping it.
        let cmd = ["sh", "-c", 'mkdir -p "$1" && shift && exec "$@"', "qs-capture", root.recDir, "wf-recorder", "-y"];
        cmd = cmd.concat(Settings.recorderArgs.split(" ").filter(a => a !== ""));
        if (geom !== "")
            cmd.push("-g", geom);
        else if (root._outputName !== "")
            cmd.push("-o", root._outputName);
        cmd.push("-f", root.recordingPath);

        root.recordingSeconds = 0;
        recProc.command = cmd;
        recProc.running = true;
    }

    // Convenience wrappers used by the overlay, in layout-global logical px.
    function shootRect(x: real, y: real, w: real, h: real): void {
        root.shootRegion(root._geom(x, y, w, h));
    }

    function recordRect(x: real, y: real, w: real, h: real): void {
        root.recordRegion(root._geom(x, y, w, h));
    }

    // Fire whatever the picker currently has selected, then dismiss it.
    // rect is null for "the whole output".
    function commit(rect: var): void {
        ShellState.close();
        root._dropFreeze();
        // Let the overlay actually unmap before anything reads the screen,
        // otherwise the dimming layer ends up in the picture.
        commitDelay.rect = rect;
        commitDelay.record = root.recordMode;
        commitDelay.intelligence = root.intelligenceMode;
        commitDelay.restart();
    }

    // ── Window enumeration ──────────────────────────────────────────────────
    // Quickshell's HyprlandToplevel exposes no geometry, so this shells out.
    function refreshWindows(): void {
        clientsProc.running = false;
        clientsProc.running = true;
    }

    readonly property string _outputName: {
        const m = Hyprland.focusedMonitor;
        return m ? m.name : "";
    }

    // ── Internals ───────────────────────────────────────────────────────────

    // Shot pipeline: capture, copy to the clipboard, then notify with the
    // GNOME-style follow-up actions. Runs detached so a lingering
    // `notify-send --wait` never blocks the shell.
    //   $1 = directory, $2 = filename, $3.. = extra grim args
    readonly property string _shotScript: 'd=$1; n=$2; shift 2
mkdir -p "$d" || exit 1
p="$d/$n"
if ! grim "$@" "$p"; then
  notify-send -a Screenshot -u critical "Screenshot failed" "grim could not capture the screen"
  exit 1
fi
wl-copy --type image/png < "$p"
qs ipc call status-events screenshot "$p" >/dev/null 2>&1 || true
act=$(notify-send -a Screenshot -i "$p" -A open=Open -A annotate=Annotate -A folder=Folder "Screenshot captured" "$n")
case "$act" in
  open) xdg-open "$p" ;;
  annotate) satty --filename "$p" --output-filename "$p" --copy-command wl-copy --early-exit all ;;
  folder) xdg-open "$d" ;;
esac'

    readonly property string _recDoneScript: 'p=$1
d=$(dirname "$p")
act=$(notify-send -a Screencast -A open=Open -A folder=Folder "Screen recording saved" "$(basename "$p")")
case "$act" in
  open) xdg-open "$p" ;;
  folder) xdg-open "$d" ;;
esac'

    function _grabFreeze(): void {
        root._prevFreezePath = root._freezePath;
        root._freezePath = "";
        // Unique name per open: QML caches Image sources by URL, so reusing one
        // path would repaint the previous frame.
        const path = Quickshell.cachePath("capture-freeze-" + Date.now() + ".ppm");
        // PPM, not PNG: this is a 4500x3000 grab and libpng would spend the
        // better part of a second compressing an image we throw away seconds
        // later. PPM is a raw dump, so the freeze appears immediately.
        // -o pins the grab to the focused output so the picker's coordinate
        // space is exactly this window's.
        const cmd = ["sh", "-c", 'mkdir -p "$(dirname "$1")" && d=$1 && shift && exec grim -t ppm "$@" "$d"', "qs-capture", path];
        if (root._outputName !== "")
            cmd.push("-o", root._outputName);
        freezeProc.pendingPath = path;
        freezeProc.command = cmd;
        freezeProc.running = false;
        freezeProc.running = true;
    }

    function _dropFreeze(): void {
        const stale = root._freezePath;
        root._freezePath = "";
        if (stale !== "")
            Quickshell.execDetached(["rm", "-f", stale]);
    }

    Timer {
        id: commitDelay
        property var rect: null
        property bool record: false
        property bool intelligence: false
        // A handful of frames at 60Hz, enough for the compositor to recomposite
        // the output without the overlay on it.
        interval: 90
        onTriggered: {
            const r = commitDelay.rect;
            const geom = r ? root._geom(r.x, r.y, r.width, r.height) : "";
            if (commitDelay.intelligence)
                ScreenIntelligence.analyzeRegion(geom, root._outputName);
            else if (commitDelay.record)
                root.recordRegion(geom);
            else
                root.shootRegion(geom);
            commitDelay.rect = null;
        }
    }

    Process {
        id: freezeProc
        property string pendingPath: ""
        onExited: (code, status) => {
            if (code === 0) {
                root._freezePath = freezeProc.pendingPath;
            } else {
                // No wlr-screencopy (or no compositor at all): still open the
                // picker so Selection/Window mode remain usable, just without
                // the frozen backdrop.
                root._freezePath = "";
                console.warn("Capture: grim freeze failed with exit code", code);
            }
            if (root._prevFreezePath !== "") {
                Quickshell.execDetached(["rm", "-f", root._prevFreezePath]);
                root._prevFreezePath = "";
            }
            ShellState.open("capture");
        }
    }

    Process {
        id: clientsProc
        command: ["hyprctl", "-j", "clients"]
        stdout: StdioCollector {
            onStreamFinished: {
                const focused = Hyprland.focusedWorkspace;
                let out = [];
                try {
                    const raw = JSON.parse(this.text);
                    for (const c of raw) {
                        if (!c.mapped || c.hidden)
                            continue;
                        if (!c.size || c.size[0] <= 0 || c.size[1] <= 0)
                            continue;
                        // Only the workspace the user is looking at. If
                        // Hyprland hasn't reported a focused workspace yet,
                        // show everything rather than nothing.
                        if (focused && c.workspace && c.workspace.id !== focused.id)
                            continue;
                        out.push({
                            address: c.address || "",
                            appId: c.class || "",
                            title: c.title || "",
                            order: c.focusHistoryID === undefined ? 999 : c.focusHistoryID,
                            x: c.at[0],
                            y: c.at[1],
                            w: c.size[0],
                            h: c.size[1]
                        });
                    }
                    // Ascending focusHistoryID == front to back, so a hit test
                    // walking this list picks the topmost window first.
                    out.sort((a, b) => a.order - b.order);
                } catch (e) {
                    // Not running under Hyprland, or hyprctl printed its
                    // "HYPRLAND_INSTANCE_SIGNATURE not set!" error. Window mode
                    // simply shows nothing instead of throwing.
                    out = [];
                }
                root.windows = out;
            }
        }
    }

    Process {
        id: activeWindowProc
        command: ["hyprctl", "-j", "activewindow"]
        stdout: StdioCollector {
            onStreamFinished: {
                try {
                    const c = JSON.parse(this.text);
                    if (c && c.at && c.size && c.size[0] > 0)
                        root.shootRect(c.at[0], c.at[1], c.size[0], c.size[1]);
                    else
                        root.shootRegion("");
                } catch (e) {
                    // No Hyprland / no focused window: fall back to the whole
                    // screen rather than doing nothing.
                    root.shootRegion("");
                }
            }
        }
    }

    Process {
        id: recProc
        onExited: (code, status) => {
            recTimer.stop();
            // SIGINT gives exit code 2 (or 130 through a shell); both mean the
            // user pressed stop and the file was finalised normally.
            if (root.recordingPath !== "") {
                StatusEvents.publish({
                    key: "capture-recording",
                    glyph: "\u{F044A}",
                    title: "Screen recording saved",
                    detail: root.recordingPath.split("/").pop(),
                    tone: "ok",
                    priority: StatusEvents.importantPriority,
                    actionId: "open-path",
                    actionData: root.recordingPath
                });
                Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]);
            }
            root.recordingPath = "";
            root.recordingSeconds = 0;
        }
        onStarted: {
            root.recordingSeconds = 0;
            recTimer.start();
            StatusEvents.publish({
                key: "capture-recording",
                glyph: "\u{F044A}",
                title: "Screen recording started",
                detail: "Panama is capturing the selected area",
                tone: "danger",
                priority: StatusEvents.criticalPriority,
                actionId: "open-activity"
            });
        }
    }

    // Only ticks while a recording is live -- nothing in this shell repaints
    // when idle.
    Timer {
        id: recTimer
        interval: 1000
        repeat: true
        onTriggered: root.recordingSeconds += 1
    }
}
