// Souveraine's phone navigation rail.
//
// This is deliberately part of the primary Souveraine shell, rather than a
// second `qs -c …` configuration. It owns the small bottom input region that
// stays available over fullscreen applications: an escalating upward gesture
// drives the dock and, pushed further, Mission Control; swipe down peels the
// nearest surface back; a double tap toggles fullscreen for the real active
// toplevel.
//
// The upward gesture is progressive, not a single threshold. As the drag
// climbs it crosses two stages — first REVEAL (the dock shows), then MISSION
// (the process/task surface, GlobalStates.missionControlOpen). The handle
// tracks the drag live: it grows, brightens and lifts toward whichever stage
// the current travel has reached, so the pill reads as "on top" of the
// motion rather than a passive strip. Release commits the stage the drag
// last held; a flick past the mission line commits Mission Control even from
// a short-but-fast throw.
//
// Discovery: releasing short of REVEAL three times in a row (without ever
// finding Mission Control) nudges the handle with a brief pulse the first
// session only — Persistent.states.navigation.missionControlDiscovered gates
// it off for good once the gesture is found. This is the Souveraine analogue
// of Launcher3's AllAppsEduView triple-swipe hint, minus the full overlay.
//
// When the on-screen keyboard is summoned the rail rides ATOP it (bottom
// margin = the keyboard's measured layer height, probed from
// `hyprctl -j layers`, namespace "osk"), and swipe down dismisses the
// keyboard first; with no keyboard up, swipe down dismisses a visible dock
// in any state (GlobalStates.dockSuppressed — pinned included).
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import Quickshell.Wayland
import qs
import qs.modules.common

PanelWindow {
    id: rail

    anchors.bottom: true
    implicitWidth: 200
    implicitHeight: Config.options?.dock.gestureRailHeight ?? 32
    // Last measured squeekboard height; the probe below corrects it every
    // time the keyboard opens (rotation changes it). 315 = portrait height
    // on the Pixel's 540x1080 logical screen, the value measured live —
    // only a first-boot fallback until the first probe lands.
    // Stevia's portrait height is 200px on the 540x1080 logical screen (the
    // probe below corrects it live per rotation). Fallback WAS 315 — a stale
    // squeekboard value that lifted the pill WAY above stevia's top edge,
    // landing it on the top key row and inside stevia's upward-opening layout
    // menu ("keyboard selection not working", 2026-07-21). The pill must stay
    // visible — it is the dismiss handle — just resting exactly at the
    // keyboard's top edge, never over the keys.
    property int oskLift: 200
    margins.bottom: GlobalStates.oskOpen ? rail.oskLift : 0
    exclusionMode: ExclusionMode.Ignore
    color: "transparent"
    // Top, not Overlay: stevia's completion bar / layout menu are child
    // surfaces of the OSK (which lives on Top). On Overlay the pill sat above
    // them, so tapping word-completion opened a menu that rendered *behind*
    // the pill and looked broken. On Top the pill shares the OSK's band and
    // those popups draw over it. Tradeoff: dock and mission control are on
    // Overlay, so the pill now sits behind them when they're open — fine,
    // since those are summoned *from* the pill and occupy the screen anyway.
    // The pill still clears fullscreen apps (below Top), which is its job.
    WlrLayershell.layer: WlrLayer.Top
    WlrLayershell.namespace: "souveraine:navigation-rail"

    // Measure the keyboard's real layer-surface height. squeekboard maps up
    // to ~1s after SetVisible on a cold start (OnScreenKeyboard.qml sleeps 1
    // before the D-Bus call), so a miss retries once.
    property int oskProbeAttempts: 0
    Connections {
        target: GlobalStates
        function onOskOpenChanged() {
            if (GlobalStates.oskOpen) {
                rail.oskProbeAttempts = 0;
                oskProbeRetry.stop();
                oskProbe.running = true;
            }
        }
    }
    Process {
        id: oskProbe
        command: ["hyprctl", "-j", "layers"]
        stdout: StdioCollector {
            onStreamFinished: {
                try {
                    const monitors = JSON.parse(text);
                    for (const mon of Object.values(monitors)) {
                        for (const surfaces of Object.values(mon.levels)) {
                            for (const s of surfaces) {
                                if (s.namespace === "osk" && s.h > 0) {
                                    rail.oskLift = s.h;
                                    return;
                                }
                            }
                        }
                    }
                } catch (e) {
                    console.log("[rail] osk layer probe parse failed:", e);
                }
                // Keyboard not mapped yet (cold start takes ~1s). Retry a
                // couple of times, then keep the last known height.
                if (GlobalStates.oskOpen && rail.oskProbeAttempts < 2)
                    oskProbeRetry.restart();
            }
        }
    }
    Timer {
        id: oskProbeRetry
        interval: 1200
        onTriggered: {
            rail.oskProbeAttempts += 1;
            if (GlobalStates.oskOpen) oskProbe.running = true;
        }
    }

    // --- Gesture stages -----------------------------------------------------
    // Upward travel, in px, at which each stage arms. The drag must climb
    // past REVEAL_AT to show the dock and past MISSION_AT to reach Mission
    // Control; releasing between the two commits the lower stage.
    readonly property int revealAt: 48
    readonly property int missionAt: 190

    // Live upward travel of the in-flight drag (0 while idle, grows as the
    // finger climbs). Drives the handle's appearance so the pill tracks the
    // motion. Only ever set by the MouseArea below.
    property real dragTravel: 0
    property bool dragging: false

    // Stage the current travel has reached: 0 none, 1 reveal, 2 mission.
    readonly property int dragStage: dragTravel >= missionAt ? 2
        : dragTravel >= revealAt ? 1 : 0

    Rectangle {
        id: handle
        anchors.horizontalCenter: parent.horizontalCenter
        // Ride upward with the drag so the pill leads the motion, clamped so
        // it stays inside the rail's own height.
        y: (parent.height - height) / 2
            - Math.min(rail.dragTravel * 0.12, (parent.height - height) / 2)

        // Grow with travel; jump wider once a stage arms so the escalation is
        // felt, not just seen.
        width: (rail.dragStage >= 2 ? 190
            : rail.dragStage >= 1 ? 170 : 150)
            + Math.min(rail.dragTravel * 0.08, 24)
        height: 7 + (rail.dragStage >= 2 ? 3 : rail.dragStage >= 1 ? 1 : 0)
        radius: height / 2

        // Brighten and tint toward the accent as the drag escalates. Stage 2
        // pulls the handle to the theme accent — the "you've reached Mission
        // Control" tell.
        color: rail.dragStage >= 2
            ? (Appearance?.colors?.colPrimary ?? "#a0c8ff")
            : "#e6ffffff"
        opacity: rail.dragging ? 1 : 0.9

        // Idle nudge for the discovery hint: a brief scale pulse.
        scale: 1
        SequentialAnimation {
            id: discoveryPulse
            running: false
            loops: 2
            NumberAnimation { target: handle; property: "scale"; to: 1.18; duration: 160; easing.type: Easing.OutQuad }
            NumberAnimation { target: handle; property: "scale"; to: 1.0; duration: 220; easing.type: Easing.InOutQuad }
        }

        Behavior on width {
            enabled: !rail.dragging
            NumberAnimation { duration: 120 }
        }
        Behavior on color {
            ColorAnimation { duration: 140 }
        }
        Behavior on y {
            enabled: !rail.dragging
            NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
        }
    }

    MouseArea {
        id: gestureArea
        anchors.fill: parent
        property real startY: 0
        property real startAt: -1
        property real lastTapAt: -1
        // Consecutive upward attempts that fell short of REVEAL without ever
        // reaching Mission Control — feeds the discovery nudge.
        property int shortSwipes: 0
        readonly property int tapSlop: 24
        readonly property int doubleTapInterval: 350
        // Fast throws commit the stage above their travel: a short-but-quick
        // flick past this speed (px/ms) escalates one stage.
        readonly property real flickSpeed: 1.1

        function toggleFullscreen(): void {
            GlobalStates.dockRevealed = false
            const raw = Hyprland.activeToplevel?.address
            if (!raw)
                return
            const address = raw.startsWith("0x") ? raw : "0x" + raw
            Quickshell.execDetached(["hyprctl", "dispatch",
                `hl.dsp.window.fullscreen({ window = "address:${address}", mode = "fullscreen", action = "toggle" })`])
        }

        // A single tap is Home. Delay it by the double-tap window so the
        // existing fullscreen gesture remains unambiguous; the second tap
        // cancels this timer before toggling fullscreen.
        function goHome(): void {
            lastTapAt = -1
            GlobalStates.missionControlOpen = false
            GlobalStates.overviewOpen = false
            GlobalStates.dockRevealed = false
            GlobalStates.oskOpen = false
            Quickshell.execDetached(["hyprctl", "dispatch",
                "hl.dsp.focus({ workspace = 1 })"])
        }

        // Commit an upward gesture. `stage` is the stage the drag settled on
        // (0/1/2). Higher stages imply the lower one, so Mission Control also
        // reveals the dock beneath it.
        function commitUp(stage: int): void {
            singleTapTimer.stop()
            lastTapAt = -1
            if (stage >= 1) {
                GlobalStates.dockSuppressed = false
                GlobalStates.dockRevealed = true
            }
            if (stage >= 2) {
                GlobalStates.missionControlOpen = true
                if (!Persistent.states.navigation.missionControlDiscovered)
                    Persistent.states.navigation.missionControlDiscovered = true
                shortSwipes = 0
            }
        }

        onPressed: mouse => {
            startY = mouse.y
            startAt = Date.now()
            rail.dragging = true
            rail.dragTravel = 0
        }

        onPositionChanged: mouse => {
            if (!rail.dragging)
                return
            // Upward travel is positive; downward drags leave travel at 0 so
            // the handle doesn't chase a dismiss gesture.
            rail.dragTravel = Math.max(0, startY - mouse.y)
        }

        onReleased: mouse => {
            const wasDragging = rail.dragging
            rail.dragging = false
            const travel = Math.max(0, startY - mouse.y)
            const deltaY = mouse.y - startY
            const elapsed = Math.max(1, Date.now() - startAt)
            const speed = travel / elapsed // px/ms, upward only
            rail.dragTravel = 0

            // Upward: pick the committed stage from travel, then let a fast
            // flick promote it one step (a quick short throw still reaches
            // Mission Control).
            if (travel >= rail.revealAt || (speed >= flickSpeed && travel >= rail.tapSlop)) {
                let stage = travel >= rail.missionAt ? 2 : travel >= rail.revealAt ? 1 : 0
                if (speed >= flickSpeed && stage < 2)
                    stage += 1
                commitUp(stage)
                return
            }

            // Downward: peel the nearest surface (keyboard, then dock).
            if (deltaY >= rail.revealAt) {
                singleTapTimer.stop()
                lastTapAt = -1
                if (GlobalStates.oskOpen) {
                    GlobalStates.oskOpen = false
                    return
                }
                if (GlobalStates.missionControlOpen) {
                    GlobalStates.missionControlOpen = false
                    return
                }
                GlobalStates.dockRevealed = false
                GlobalStates.dockSuppressed = true
                return
            }

            // Neither committed: an upward attempt that fell short of REVEAL
            // counts toward the discovery nudge; anything else resets it.
            if (travel > tapSlop) {
                singleTapTimer.stop()
                lastTapAt = -1
                maybeNudgeDiscovery()
                return
            }
            if (Math.abs(deltaY) > tapSlop) {
                singleTapTimer.stop()
                lastTapAt = -1
                return
            }

            const now = Date.now()
            if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
                singleTapTimer.stop()
                lastTapAt = -1
                toggleFullscreen()
            } else {
                lastTapAt = now
                singleTapTimer.restart()
            }
        }

        Timer {
            id: singleTapTimer
            interval: gestureArea.doubleTapInterval
            onTriggered: gestureArea.goHome()
        }

        // Count a short upward attempt; after three in a row, and only until
        // the gesture has been discovered, pulse the handle once.
        function maybeNudgeDiscovery(): void {
            if (Persistent.states.navigation.missionControlDiscovered)
                return
            shortSwipes += 1
            if (shortSwipes >= 3) {
                shortSwipes = 0
                discoveryPulse.restart()
            }
        }

        onCanceled: {
            singleTapTimer.stop()
            rail.dragging = false
            rail.dragTravel = 0
            lastTapAt = -1
        }
    }
}
