#!/bin/bash
#
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# Read, set and watch desktop modes. The modes are files under
# XDG_RUNTIME_DIR holding 0 or 1; a missing file means off.
#
# This talks to the files, not to the shell, so it works while quickshell is
# down. Setting a mode that way records the state without firing its effects;
# the shell sees the change through its own watch and reasserts them.
#
#   statusctl <mode> get          prints 0 or 1
#   statusctl <mode> set 0|1
#   statusctl <mode> toggle
#   statusctl <mode> watch        waybar JSON on every change

set -u

MODES="dnd presentation nolock"
DIR="${XDG_RUNTIME_DIR:-/tmp}"

usage() {
    printf 'usage: %s <%s> <get|set 0|1|toggle|watch>\n' \
           "${0##*/}" "$(printf '%s' "$MODES" | tr ' ' '|')" >&2
    exit 1
}

[[ $# -ge 2 ]] || usage

mode="$1"
action="$2"

# A typo must fail loudly rather than read as a mode that happens to be off.
case " $MODES " in
    *" $mode "*) ;;
    *) printf '%s: unknown mode: %s\n' "${0##*/}" "$mode" >&2; exit 1 ;;
esac

file="$DIR/status.$mode"

read_mode() {
    local v=""
    # A missing file is the normal state before anything has written one and
    # reads as off. Guarding on existence keeps the shell's redirection error
    # off stderr: `2>/dev/null` on the command cannot suppress a failure of
    # its own input redirect.
    [[ -e "$file" ]] && v="$(tr -d '[:space:]' < "$file" 2>/dev/null)"
    [[ "$v" == "1" ]] && printf '1' || printf '0'
}

# Write through a temporary file and rename, so no reader ever sees a
# half-written value. This is also what FileView does on the QML side, and it
# is why a watcher has to listen for moved_to as well as close_write.
write_mode() {
    local want="$1" tmp
    tmp="$(mktemp "$DIR/.status.$mode.XXXXXX")" || exit 1
    printf '%s\n' "$want" > "$tmp"
    # The temp file is made in the same directory as the target, so this is a
    # rename rather than a copy, and therefore atomic. A failure here has to
    # be loud: reporting success on a write that did not land would leave the
    # caller and the shell disagreeing about the mode, with an orphan temp
    # file as the only trace.
    mv -f "$tmp" "$file" || { rm -f "$tmp"; exit 1; }
}

emit() {
    local state="$1"
    printf '{"text": "", "alt": "%s", "class": "%s", "tooltip": "%s"}\n' \
           "$state" "$state" "$(tooltip "$state")"
}

tooltip() {
    case "$1" in
        activated)   printf '%s: on' "$mode" ;;
        deactivated) printf '%s: off' "$mode" ;;
        gaming)      printf '%s: on (game running)' "$mode" ;;
    esac
}

# The shell's game detector owns status.gaming and presentation is the OR of
# the two, so the CLI has to read it to agree with the shell. It is not a mode
# in MODES: nothing here may write it, and only presentation is affected.
gaming_on() {
    local v=""
    [[ -e "$DIR/status.gaming" ]] && v="$(tr -d '[:space:]' < "$DIR/status.gaming" 2>/dev/null)"
    [[ "$v" == "1" ]]
}

# A missing file is the off state, not a distinct condition: read_mode already
# reads it as 0, and this is the same path `get` uses. There is no "down": a
# file cannot report whether a watcher is alive, and a missing one is exactly
# what a fresh session looks like.
#
# A running game reports its own state rather than plain activated, so the
# widget can show why the mode is on and that a click will not turn it off.
state_now() {
    if [[ "$mode" == "presentation" ]] && gaming_on; then
        printf 'gaming'
    elif [[ "$(read_mode)" == "1" ]]; then
        printf 'activated'
    else
        printf 'deactivated'
    fi
}

case "$action" in
    get)
        read_mode
        printf '\n'
        ;;
    set)
        [[ $# -eq 3 ]] || usage
        case "$3" in
            0|1) write_mode "$3" ;;
            *) usage ;;
        esac
        ;;
    toggle)
        # A running game pins presentation on and the detector reasserts it
        # within its poll, so a toggle here would flip back on its own. Refuse
        # it instead of writing a value that does not stick. `set` still works:
        # it is the explicit verb, and the manual half is worth setting for
        # when the game exits.
        if [[ "$mode" == "presentation" ]] && gaming_on; then
            printf '%s: presentation held on by a running game\n' "${0##*/}" >&2
            exit 1
        fi
        [[ "$(read_mode)" == "1" ]] && write_mode 0 || write_mode 1
        ;;
    watch)
        emit "$(state_now)"
        # Watch the directory rather than the file: an atomic write replaces
        # the file, so a watch held on the old inode dies with it. This is the
        # same trap the mail watcher hit with Xapian.
        #
        # inotifywait must die with us. Piped straight into the while loop it
        # would be a pipeline sibling, not a child, so a plain kill on this
        # process (which is exactly how waybar stops and respawns its exec
        # children on every reload) leaves it running, watching a directory
        # nobody reads anymore. Process substitution makes it a real child
        # whose PID we can hold and kill from a trap.
        exec 3< <(inotifywait -q -m -e close_write,moved_to,delete --format '%f' "$DIR" 2>/dev/null)
        watcher=$!
        trap 'kill "$watcher" 2>/dev/null' EXIT TERM INT
        # Presentation also follows status.gaming, so a game starting or
        # ending redraws the widget. Emitting only on a real change keeps a
        # write of an unchanged value from firing a duplicate line.
        last="$(state_now)"
        while read -r changed <&3; do
            case "$changed" in
                "status.$mode") ;;
                status.gaming) [[ "$mode" == "presentation" ]] || continue ;;
                *) continue ;;
            esac
            now="$(state_now)"
            [[ "$now" == "$last" ]] && continue
            last="$now"
            emit "$now"
        done
        ;;
    *)
        usage
        ;;
esac
