#!/bin/bash
# THE ONE PRIVILEGED SURFACE ON THE KDS APPLIANCE.
#
# Why this exists at all: the appliance had NO route to a software update. The
# self-updater's Linux .deb path shells out to `pkexec dpkg -i`, and the image
# ships neither polkit nor an authentication agent — and could not usefully ship
# one, because a kiosk has nobody to answer a password prompt. Proven on the
# first real unit: `command -v pkexec` empty, `sudo -n true` → "interactive
# authentication is required". Every future fix would have meant re-flashing a
# USB stick and driving to the branch.
#
# So the app calls exactly one command through sudo, and that command lives
# HERE. The sudoers entry names this path and nothing else, so the privileged
# surface is this file's argument parser rather than "dpkg" or "systemctl".
#
# THE IMPORTANT PROPERTY: this script ships INSIDE the app bundle
# (assets/appliance/) and `sync` copies it over itself. A bug in this file is
# therefore fixable by a normal app update — the same OTA that fixes a button
# label. The only pieces that can never be updated remotely are the one-line
# sudoers rule and the boot-time oneshot that calls `sync`, both of which are
# deliberately trivial and have no logic to get wrong.
#
# Everything is idempotent: `sync` is safe to run on every boot and after every
# update, and only restarts the kiosk when a file actually changed.
set -u

MARKER=/etc/maestro-kds/appliance
CONFDIR=/etc/maestro-kds
APPROOT=/opt/maestro-kds
ASSETS="$APPROOT/data/flutter_assets/assets/appliance"
LOG=/var/log/maestro-kds-ctl.log

log() { printf '%s %s\n' "$(date -Is)" "$*" >>"$LOG" 2>/dev/null; }
die() { echo "maestro-kds-ctl: $*" >&2; log "ERROR $*"; exit 1; }

[ "$(id -u)" = 0 ] || die "must run as root (via sudo)"

# ── helpers ────────────────────────────────────────────────────────────────

# Restart the kiosk WITHOUT being killed by the restart. The caller is the
# Flutter app, which is a grandchild of maestro-kds.service — a plain
# `systemctl restart` from inside the unit's own cgroup tears down the process
# that issued it, so the command never returns and its exit status is a lie.
# Handing the restart to a transient unit detaches it from this cgroup.
restart_kiosk() {
    log "scheduling kiosk restart"
    systemd-run --quiet --on-active=2 --unit=maestro-kds-restart \
        systemctl restart maestro-kds.service 2>/dev/null \
        || systemctl restart --no-block maestro-kds.service
}

# The kiosk session user — sway runs here and owns its IPC socket.
KIOSK_USER=kds

# Talk to the RUNNING compositor. Config on disk only takes effect when sway
# reads it (at start), so a rotate or a cursor change written to sway.conf does
# nothing to a board that is already up — which is exactly why rotating moved
# the touch (the shim re-reads orientation itself) but not the picture. This
# sends sway the change directly.
#
# A full `swaymsg reload` is deliberately NOT used: it re-runs exec_always and
# would launch a SECOND copy of the app. Best-effort — a quiet no-op when sway
# is not up yet (e.g. called from sync at boot), so callers need not guard it.
sway_msg() {
    local uid sock
    uid="$(id -u "$KIOSK_USER" 2>/dev/null)" || return 1
    sock="$(ls -1 /run/user/"$uid"/sway-ipc."$uid".*.sock 2>/dev/null | head -n1)"
    [ -n "$sock" ] || return 1
    runuser -u "$KIOSK_USER" -- env SWAYSOCK="$sock" swaymsg "$@" >/dev/null 2>&1
}

# Push the rendered sway.conf's orientation + cursor timeout onto the LIVE board
# so a rotate, or an OTA that changed either, takes effect immediately instead of
# at the next reboot.
apply_live_config() {
    # Under gnome-kiosk (mutter) rotation is a compositor transform applied
    # over D-Bus — sway.conf is not consulted and swaymsg has nobody to talk
    # to. The helper maps the orientation file straight onto mutter.
    if pgrep -x gnome-kiosk >/dev/null 2>&1; then
        /usr/local/bin/maestro-kds-rotate-mutter \
            "$(cat "$CONFDIR/orientation" 2>/dev/null || echo normal)" || true
        return 0
    fi
    local conf="$CONFDIR/sway.conf" tf hc
    [ -f "$conf" ] || return 0
    tf="$(grep -oE 'transform [a-z0-9]+' "$conf" | awk '{print $2}' | head -n1)"
    hc="$(grep -oE 'hide_cursor [0-9]+' "$conf" | awk '{print $2}' | head -n1)"
    [ -n "$tf" ] && sway_msg output '*' transform "$tf"
    [ -n "$hc" ] && sway_msg seat '*' hide_cursor "$hc"
    return 0
}

# Copy only if different; echo 1 when it changed so callers can decide whether a
# restart is warranted.
install_if_changed() { # $1=src $2=dst $3=mode
    local src="$1" dst="$2" mode="$3"
    [ -f "$src" ] || return 0
    if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
        chmod "$mode" "$dst" 2>/dev/null
        return 0
    fi
    install -D -m "$mode" "$src" "$dst" || return 0
    log "updated $dst"
    echo 1
}

# ── verbs ──────────────────────────────────────────────────────────────────

# sync — plant every piece of kiosk plumbing carried in the app bundle.
#
# This is what makes the appliance re-flash-free. The session script, the sway
# config template, the systemd units, the udev rules and this very helper all
# travel as app assets, so an app update ships new plumbing and `sync` applies
# it. Before this, all of that lived only in the USB image: six separate fixes
# on the first unit's first night would each have been a re-flash.
cmd_sync() {
    [ -d "$ASSETS" ] || die "no appliance assets at $ASSETS"
    local changed=""

    changed+="$(install_if_changed "$ASSETS/maestro-kds-ctl"        /usr/local/bin/maestro-kds-ctl       0755)"
    changed+="$(install_if_changed "$ASSETS/maestro-kds-session"    /usr/local/bin/maestro-kds-session   0755)"
    changed+="$(install_if_changed "$ASSETS/maestro-kds-orientation" /usr/local/bin/maestro-kds-orientation 0755)"
    changed+="$(install_if_changed "$ASSETS/maestro-kds-rotate-mutter" /usr/local/bin/maestro-kds-rotate-mutter 0755)"
    changed+="$(install_if_changed "$ASSETS/maestro-kds-setup"      /usr/local/bin/maestro-kds-setup     0755)"
    changed+="$(install_if_changed "$ASSETS/maestro-kds.service"    /etc/systemd/system/maestro-kds.service 0644)"
    changed+="$(install_if_changed "$ASSETS/maestro-kds-setup.service" /etc/systemd/system/maestro-kds-setup.service 0644)"
    changed+="$(install_if_changed "$ASSETS/maestro-kds-sync.service" /etc/systemd/system/maestro-kds-sync.service 0644)"
    touchbin="$(install_if_changed "$ASSETS/maestro-touch-pointer"    /usr/local/bin/maestro-touch-pointer 0755)"
    changed+="$touchbin"
    changed+="$(install_if_changed "$ASSETS/maestro-printer-bridge"   /usr/local/bin/maestro-printer-bridge 0755)"
    changed+="$(install_if_changed "$ASSETS/maestro-printer-bridge.service" /etc/systemd/system/maestro-printer-bridge.service 0644)"
    touchsvc="$(install_if_changed "$ASSETS/maestro-touch-pointer.service" /etc/systemd/system/maestro-touch-pointer.service 0644)"
    changed+="$touchsvc"
    changed+="$(install_if_changed "$ASSETS/maestro-kds-watchdog"         /usr/local/bin/maestro-kds-watchdog 0755)"
    changed+="$(install_if_changed "$ASSETS/maestro-kds-watchdog.service" /etc/systemd/system/maestro-kds-watchdog.service 0644)"
    changed+="$(install_if_changed "$ASSETS/maestro-kds-watchdog.timer"   /etc/systemd/system/maestro-kds-watchdog.timer 0644)"
    mkdir -p /etc/systemd/journald.conf.d /etc/systemd/system.conf.d /etc/modules-load.d
    changed+="$(install_if_changed "$ASSETS/journald-maestro.conf" /etc/systemd/journald.conf.d/maestro.conf 0644)"
    changed+="$(install_if_changed "$ASSETS/watchdog-maestro.conf" /etc/systemd/system.conf.d/maestro-watchdog.conf 0644)"
    changed+="$(install_if_changed "$ASSETS/modules-maestro-watchdog.conf" /etc/modules-load.d/maestro-watchdog.conf 0644)"
    # The sudoers rule is re-planted too, but at 0440 and only after visudo
    # accepts it: a malformed file in /etc/sudoers.d breaks sudo for the whole
    # machine, and on an appliance that means losing the only route to fixing it.
    if [ -f "$ASSETS/sudoers-maestro-kds" ]; then
        tmp="$(mktemp)"
        cp "$ASSETS/sudoers-maestro-kds" "$tmp"
        chmod 0440 "$tmp"
        if visudo -cf "$tmp" >/dev/null 2>&1; then
            changed+="$(install_if_changed "$tmp" /etc/sudoers.d/maestro-kds 0440)"
        else
            log "REFUSED to install sudoers file: visudo rejected it"
        fi
        rm -f "$tmp"
    fi
    local udev=""
    udev+="$(install_if_changed "$ASSETS/99-maestro-printer.rules" /etc/udev/rules.d/99-maestro-printer.rules 0644)"
    udev+="$(install_if_changed "$ASSETS/99-maestro-touch.rules"   /etc/udev/rules.d/99-maestro-touch.rules   0644)"

    # The boot/shutdown splash theme travels with the app too, so a splash fix
    # is an ordinary OTA. After planting, the unit's orientation is re-applied
    # into the script (the shipped literal is 0) and the initrd is re-baked in
    # the background so the BOOT copy matches; the shutdown splash reads the
    # rootfs copy immediately.
    local plych=""
    if [ -d "$ASSETS/plymouth" ]; then
        for f in "$ASSETS/plymouth/"*; do
            plych+="$(install_if_changed "$f" "/usr/share/plymouth/themes/maestro/$(basename "$f")" 0644)"
        done
        if [ -n "$plych" ]; then
            apply_plymouth_orientation
            changed+="$plych"
        fi
    fi

    # The sway config is generated, not copied: the orientation is per-unit and
    # chosen by whoever hung the screen, so a shipped file would overwrite their
    # answer on every update.
    render_sway && changed+="1"

    if [ -n "$udev" ]; then
        udevadm control --reload 2>/dev/null
        udevadm trigger --subsystem-match=input 2>/dev/null
        changed+="1"
    fi
    if [ -n "$changed" ]; then
        systemctl daemon-reload 2>/dev/null
        # These are pulled in by multi-user.target rather than by the kiosk, so
        # a first install has to enable them explicitly — otherwise the plumbing
        # is present, correct, and never runs. Both are idempotent.
        systemctl enable maestro-kds-sync.service >/dev/null 2>&1
        systemctl enable maestro-touch-pointer.service >/dev/null 2>&1
        systemctl enable maestro-printer-bridge.service >/dev/null 2>&1
        # The self-heal timer: restarts dead services past their StartLimit,
        # reconnects Wi-Fi, and bounces a silently-dead support VPN.
        systemctl enable --now maestro-kds-watchdog.timer >/dev/null 2>&1
        # Restart the translator when its own code changed, so a touch fix
        # shipped over the air takes effect without waiting for a reboot. Keys off
        # BOTH the script and the unit file: a fix almost always lands in the
        # script, and gating only on the unit (as this once did) shipped the fix
        # to disk while the old code kept running until the next reboot.
        if [ -n "$touchsvc$touchbin" ] && [ -e "$MARKER" ]; then
            systemctl restart maestro-touch-pointer.service >/dev/null 2>&1
        fi
        # An OTA that changed sway.conf (e.g. the cursor timeout) should show on
        # the board without waiting for a reboot.
        apply_live_config
    fi
    # Host mutations ride every sync and gate themselves by content stamps —
    # they run on first install, after an OTA that changed them, and never
    # otherwise. AFTER the plumbing plant so payloads reference fresh assets.
    run_host_setup
    if [ -n "$changed" ]; then
        echo "changed"
    else
        echo "unchanged"
    fi
}

# Write sway.conf from the template + the unit's own orientation answer.
render_sway() {
    local tmpl="$ASSETS/sway.conf.tmpl" out="$CONFDIR/sway.conf" tf hc
    [ -f "$tmpl" ] || return 1
    # Default is PORTRAIT (90): the panel is a vertically-hung kitchen board, so
    # portrait is right far more often than not, and a fresh flash that came up
    # landscape was exactly the gap this closes. An explicit choice in
    # $CONFDIR/orientation always wins; `rotate normal` makes it landscape.
    tf=90
    [ -r "$CONFDIR/orientation" ] && tf="$(tr -d '[:space:]' <"$CONFDIR/orientation")"
    case "$tf" in normal|90|180|270) ;; *) tf=90 ;; esac
    # Cursor idle-hide timeout (ms). Default 100 = hidden as tightly as sway
    # allows; 0 keeps a mouse cursor up for setup. A missing or garbage value
    # falls back to the hidden default. The template already carries a valid
    # literal (100), so we REWRITE the number rather than substitute a token:
    # that keeps the file renderable by an older ctl mid-upgrade (see the note in
    # sway.conf.tmpl) instead of leaving a token sway would reject.
    hc=100
    [ -r "$CONFDIR/cursor" ] && hc="$(tr -cd '0-9' <"$CONFDIR/cursor")"
    [ -n "$hc" ] || hc=100
    mkdir -p "$CONFDIR"
    local tmp; tmp="$(mktemp)"
    sed -e "s|@TRANSFORM@|$tf|g" \
        -e "s|hide_cursor [0-9][0-9]*|hide_cursor $hc|" "$tmpl" >"$tmp"
    if [ -f "$out" ] && cmp -s "$tmp" "$out"; then rm -f "$tmp"; return 1; fi
    install -m 0644 "$tmp" "$out"; rm -f "$tmp"
    log "rendered sway.conf transform=$tf hide_cursor=$hc"
    return 0
}

# apply-update — install a staged .deb and bring the board back.
#
# The path is validated rather than trusted: this runs as root from a sudo rule,
# so "whatever the caller passes" must not become "install any package on the
# system". It has to be a regular file, a .deb, and sit in the update staging
# directory the app itself writes to.
# ── HOST-SETUP.D — the OTA channel for HOST mutations ───────────────────────
# Numbered scripts shipped in the bundle's assets/host-setup.d/ run here as
# root, ONCE per content version: a stamp in /etc/maestro-kds/applied/ holds
# the sha256 that last ran, so an edited script re-runs and an unchanged one
# never does. This is what lets a unit flashed from an old image CONVERGE to
# the current host state over the air (masks, themes, trims) instead of
# drifting until a re-flash.
#
# THE CONTRACT every script must honour:
#   • idempotent — it may run again after an edit, on any base state
#   • exit 0 only on success — failure logs and retries on the next sync
#   • never touches bootloader, kernel or sudoers (image/phase-2 territory)
#   • compatible with the PREVIOUS app version — an A/B rollback restores the
#     old bundle but host mutations persist, exactly like node DB migrations
run_host_setup() {
    local dir="$ASSETS/host-setup.d" stampdir=/etc/maestro-kds/applied
    [ -d "$dir" ] || return 0
    mkdir -p "$stampdir"
    local s name hash stamp
    for s in "$dir"/[0-9]*.sh; do
        [ -f "$s" ] || continue
        name="$(basename "$s")"
        hash="$(sha256sum "$s" | cut -d' ' -f1)"
        stamp="$stampdir/$name"
        [ "$(cat "$stamp" 2>/dev/null)" = "$hash" ] && continue
        log "host-setup: running $name"
        if HOST_SETUP_ASSETS="$dir" timeout 300 sh "$s" >>"$LOG" 2>&1; then
            printf '%s' "$hash" > "$stamp"
            log "host-setup: $name ok"
        else
            log "host-setup: $name FAILED (exit $?) — will retry next sync"
        fi
    done
}

# ── A/B: the previous bundle is kept, and coming back to it is one command ──
APP_DIR=/opt/maestro-kds
PREV_DIR=/opt/maestro-kds.prev
PENDING=/etc/maestro-kds/update-pending

cmd_rollback() {
    [ -d "$PREV_DIR" ] || die "no previous bundle at $PREV_DIR"
    log "ROLLBACK: restoring previous bundle"
    systemctl stop maestro-kds.service 2>/dev/null || true
    rm -rf "$APP_DIR.bad"
    mv "$APP_DIR" "$APP_DIR.bad" 2>/dev/null || true
    cp -a "$PREV_DIR" "$APP_DIR"
    rm -f "$PENDING"
    systemctl daemon-reload 2>/dev/null || true
    systemctl restart maestro-kds.service 2>/dev/null || true
    log "rollback complete (bad bundle kept at $APP_DIR.bad; next sync converges plumbing to the restored bundle)"
    echo "ok"
}

cmd_apply_update() {
    local deb="${1:-}"
    [ -n "$deb" ] || die "apply-update needs a .deb path"
    [ -f "$deb" ] || die "no such file: $deb"
    case "$deb" in
        /home/*/.cache/maestro-kds/updates/*/*.deb) ;;
        *) die "refusing a .deb outside the update staging directory: $deb" ;;
    esac
    case "$deb" in *..*) die "refusing a path containing .." ;; esac

    # Confirm it is actually our package before handing it to dpkg.
    local pkg
    pkg="$(dpkg-deb -f "$deb" Package 2>/dev/null || true)"
    [ "$pkg" = "maestro-kds" ] || die "not a maestro-kds package (Package=$pkg)"

    # A/B: snapshot the running bundle BEFORE dpkg touches it. cp -a to a tmp
    # then swap, so a power cut mid-snapshot can never leave a torn .prev.
    if [ -d "$APP_DIR" ]; then
        log "snapshotting current bundle for rollback"
        rm -rf "$PREV_DIR.tmp"
        if cp -a "$APP_DIR" "$PREV_DIR.tmp" 2>>"$LOG"; then
            rm -rf "$PREV_DIR"
            mv "$PREV_DIR.tmp" "$PREV_DIR"
        else
            rm -rf "$PREV_DIR.tmp"
            log "snapshot failed — proceeding WITHOUT a rollback point"
        fi
    fi

    log "installing $deb"
    if ! dpkg -i "$deb" >>"$LOG" 2>&1; then
        log "dpkg failed; attempting dependency repair"
        if ! apt-get -y -f install >>"$LOG" 2>&1; then
            if [ -d "$PREV_DIR" ]; then
                log "install failed — restoring the previous bundle"
                cmd_rollback >/dev/null || true
                die "dpkg -i failed (previous bundle restored)"
            fi
            die "dpkg -i failed"
        fi
    fi
    # Health gate: the watchdog confirms this update once the app's frame
    # heartbeat proves the new build renders; if it never does, the watchdog
    # rolls back to the snapshot automatically. The marker carries install
    # time; heartbeat mtime > marker mtime = the NEW build painted a frame.
    date +%s > "$PENDING"
    cmd_sync >/dev/null
    restart_kiosk
    echo "ok"
}

# Face the boot/shutdown splash the way the unit is mounted: sed the unit's
# orientation into the theme script as a literal, then re-bake the initrd in
# the BACKGROUND so the boot-time copy matches (20-40s on this hardware —
# nothing a rotate tap should wait on; the rootfs copy covers shutdown at
# once, and the next boot reads the fresh initrd).
apply_plymouth_orientation() {
    local script=/usr/share/plymouth/themes/maestro/maestro.script deg v
    [ -f "$script" ] || return 0
    v="$(cat "$CONFDIR/orientation" 2>/dev/null || echo normal)"
    # Plymouth's Image.Rotate spins the OPPOSITE way from mutter's transform
    # (measured on the unit: mutter-270 upright needed plymouth-90; a 270 bake
    # came up 180 from correct). 180 is its own mirror and stays put.
    case "$v" in 90) deg=270 ;; 180) deg=180 ;; 270) deg=90 ;; *) deg=0 ;; esac
    sed -i "s/^orient = [0-9][0-9]*;/orient = ${deg};/" "$script"
    # The intro lives in the initramfs, so it only follows this edit after a
    # regen. Stamp the intent FIRST: a power-cut mid-regen otherwise leaves the
    # boot intro on the old orientation forever — the watchdog finishes the job.
    if command -v dracut >/dev/null 2>&1; then
        : >"$CONFDIR/initramfs-pending"
        ( { dracut -f >/dev/null 2>&1 && rm -f "$CONFDIR/initramfs-pending"; } & )
    fi
    return 0
}

cmd_rotate() {
    local v="${1:-}"
    case "$v" in normal|90|180|270) ;; *) die "rotate needs normal|90|180|270" ;; esac
    mkdir -p "$CONFDIR"
    printf '%s\n' "$v" >"$CONFDIR/orientation"
    render_sway || true
    apply_boot_orientation "$v"
    apply_plymouth_orientation
    # Rotate the LIVE picture too. Without this the board only rotated on the
    # next restart, so the panel looked unchanged while the digitiser (which the
    # shim rotates itself) had already moved — the exact split the field hit.
    apply_live_config
    log "orientation set to $v"
    echo "ok"
}

# THE SCREEN MUST BE THE RIGHT WAY UP FROM POWER-ON, NOT FROM LOGIN.
#
# A sway output transform only exists once the compositor is running, so on a
# portrait-mounted panel the whole boot — firmware handoff, the kernel's last
# lines, and the Maestro splash — renders sideways, and the first thing anyone
# sees on a new appliance is a logo lying on its side.
#
# The kernel can be told how the panel is physically mounted, which fixes it for
# everyone below the compositor at once: DRM re-orients, fbcon rotates the text
# console, and plymouth follows the DRM panel orientation without being
# configured separately.
apply_boot_orientation() {
    local v="$1" panel fbcon grub=/etc/default/grub
    [ -f "$grub" ] || return 0
    case "$v" in
        90)  panel=right_side_up; fbcon=1 ;;
        180) panel=upside_down;   fbcon=2 ;;
        270) panel=left_side_up;  fbcon=3 ;;
        *)   panel=normal;        fbcon=0 ;;
    esac
    # Under gnome-kiosk the cmdline stays at panel-NATIVE (normal), and the
    # compositor transform carries the WHOLE orientation. The kernel-owned
    # experiment (2026-08-19) died on empirical evidence: video=...:
    # panel_orientation interacts with the RTK panel's own orientation quirk
    # and landed every choice exactly 90° short — DRM's mounting semantics on
    # a panel that already lies about itself are not a foundation. Boot chrome
    # renders panel-native (upright portrait on this fleet — correct for the
    # standard vertical mount); the compositor is up within seconds and exact.
    if [ -x /usr/bin/gnome-kiosk ]; then panel=normal; fbcon=0; fi

    # Apply to whatever the panel connector is actually called rather than
    # assuming eDP-1: these boards also ship DSI and LVDS panels, and a video=
    # argument naming a connector that does not exist is silently ignored — the
    # worst kind of failure, since everything looks configured and nothing moved.
    local conn
    conn="$(for c in /sys/class/drm/card*-*/status; do
                [ "$(cat "$c" 2>/dev/null)" = connected ] || continue
                d="${c%/status}"; n="${d##*/}"; echo "${n#card*-}"; break
            done)"
    [ -n "$conn" ] || conn=eDP-1

    local args="video=${conn}:panel_orientation=${panel} fbcon=rotate:${fbcon}"
    local cur new
    cur="$(sed -n 's/^GRUB_CMDLINE_LINUX_DEFAULT="\(.*\)"$/\1/p' "$grub" | head -1)"
    # Strip any previous answer before adding this one, or the cmdline collects
    # one orientation per time somebody changed their mind and the kernel takes
    # the first.
    new="$(printf '%s' "$cur" | sed -E 's/ ?video=[^ ]*panel_orientation=[^ ]*//g; s/ ?fbcon=rotate:[0-9]//g')"
    new="$(printf '%s %s' "$new" "$args" | sed -E 's/^ +//; s/ +/ /g')"
    [ "$cur" = "$new" ] && return 0

    sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT=\"$new\"|" "$grub"
    if command -v update-grub >/dev/null 2>&1; then
        update-grub >>"$LOG" 2>&1 || log "update-grub failed"
    else
        grub-mkconfig -o /boot/grub/grub.cfg >>"$LOG" 2>&1 || log "grub-mkconfig failed"
    fi
    log "boot orientation: $args (takes effect next boot)"
}

cmd_brightness() {
    local pct="${1:-}"
    case "$pct" in ''|*[!0-9]*) die "brightness needs 0-100" ;; esac
    [ "$pct" -ge 0 ] && [ "$pct" -le 100 ] || die "brightness needs 0-100"
    local dev max val found=0
    for dev in /sys/class/backlight/*; do
        [ -e "$dev/max_brightness" ] || continue
        max="$(cat "$dev/max_brightness")"
        # Never allow 0: a kitchen screen at zero backlight reads as a dead
        # appliance and there is no on-screen way back from it.
        val=$(( max * pct / 100 ))
        [ "$val" -lt $(( max / 20 + 1 )) ] && val=$(( max / 20 + 1 ))
        echo "$val" >"$dev/brightness" 2>/dev/null && found=1
    done
    [ "$found" = 1 ] || die "no backlight device on this machine"
    echo "ok"
}

# cursor hidden|shown|status — show or hide the pointer on the board.
#
# The touch shim (maestro-touch-pointer) warps an ABSOLUTE pointer to every tap
# so GTK gets a clean event stream, which means an arrow appears at each touch.
# `hidden` (the default) clamps sway's idle-hide to its tightest so the arrow is
# gone the instant a finger lifts; `shown` disables the hide so a real mouse
# stays visible while somebody sets the unit up. The choice persists in
# $CONFDIR/cursor and is pushed onto the LIVE board at once (no restart, same as
# rotate) via the hide_cursor value apply_live_config already reads back.
cmd_cursor() {
    local v="${1:-status}"
    case "$v" in
        status)
            local hc=100
            [ -r "$CONFDIR/cursor" ] && hc="$(tr -cd '0-9' <"$CONFDIR/cursor")"
            [ "${hc:-100}" = 0 ] && echo shown || echo hidden
            return 0 ;;
        hidden|hide|on)  v=100 ;;
        shown|show|off)  v=0 ;;
        *) die "cursor needs hidden|shown|status" ;;
    esac
    mkdir -p "$CONFDIR"
    printf '%s\n' "$v" >"$CONFDIR/cursor"
    render_sway || true
    apply_live_config
    log "cursor hide timeout set to $v"
    echo "ok"
}

cmd_set_locale() { # ar|en — re-literal the boot theme's language
    v="${1:?set-locale needs ar|en}"
    case "$v" in ar|en) : ;; *) die "set-locale: '$v' is not ar|en" ;; esac
    s=/usr/share/plymouth/themes/maestro/maestro.script
    [ -f "$s" ] || { echo "no theme"; return 0; }
    if grep -q "^lang = \"$v\";" "$s"; then
        echo "unchanged"
        return 0
    fi
    sed -i "s/^lang = .*/lang = \"$v\";/" "$s"
    log "boot locale -> $v (rebaking initrd)"
    # Foreground on purpose: a backgrounded dracut once raced a reboot and
    # shipped a half-baked initrd. Locale changes are rare; the wait is fine.
    dracut -f >/dev/null 2>&1
    echo ok
}

cmd_reboot()  { log "reboot requested";  systemd-run --quiet --on-active=1 systemctl reboot   || systemctl reboot;   echo ok; }
cmd_poweroff() { log "poweroff requested"; systemd-run --quiet --on-active=1 systemctl poweroff || systemctl poweroff; echo ok; }

# ── network ────────────────────────────────────────────────────────────────
# Reading state needs no privilege and the app does it directly; only the
# mutations come through here.

cmd_net_connect() { # ssid psk
    local ssid="${1:-}" psk="${2:-}"
    [ -n "$ssid" ] || die "net-connect needs an SSID"
    if [ -n "$psk" ]; then
        nmcli device wifi connect "$ssid" password "$psk" 2>&1 | tail -2
    else
        nmcli device wifi connect "$ssid" 2>&1 | tail -2
    fi
}

cmd_net_forget() { nmcli connection delete id "${1:?net-forget needs a name}" 2>&1 | tail -1; }

cmd_net_dhcp() { # iface
    local i="${1:?net-dhcp needs an interface}" c
    c="$(nmcli -g GENERAL.CONNECTION device show "$i" 2>/dev/null)"
    [ -n "$c" ] || die "no active connection on $i"
    nmcli connection modify "$c" ipv4.method auto ipv4.addresses "" ipv4.gateway "" ipv4.dns "" 2>&1 | tail -1
    nmcli connection up "$c" >/dev/null 2>&1
    echo ok
}

cmd_net_static() { # iface cidr gateway dns
    local i="${1:?}" addr="${2:?}" gw="${3:-}" dns="${4:-}" c
    c="$(nmcli -g GENERAL.CONNECTION device show "$i" 2>/dev/null)"
    [ -n "$c" ] || die "no active connection on $i"
    nmcli connection modify "$c" ipv4.method manual ipv4.addresses "$addr" \
        ${gw:+ipv4.gateway "$gw"} ${dns:+ipv4.dns "$dns"} 2>&1 | tail -1
    nmcli connection up "$c" >/dev/null 2>&1
    echo ok
}

usage() {
    cat <<'USAGE'
maestro-kds-ctl <verb> [args]
  sync                          install kiosk plumbing carried in the app bundle
  apply-update <deb>            install a staged Maestro KDS .deb and restart
  rotate normal|90|180|270      persist screen orientation
  brightness <0-100>            set panel backlight
  cursor hidden|shown|status    hide/show the pointer on the board
  reboot | poweroff             restart / shut down the appliance
  net-connect <ssid> [psk]      join a wifi network
  net-forget <name>             delete a saved connection
  net-dhcp <iface>              switch an interface to DHCP
  net-static <iface> <cidr> [gw] [dns]
USAGE
}

case "${1:-}" in
    sync)         shift; cmd_sync "$@" ;;
    apply-update) shift; cmd_apply_update "$@" ;;
    rotate)       shift; cmd_rotate "$@" ;;
    brightness)   shift; cmd_brightness "$@" ;;
    cursor)       shift; cmd_cursor "$@" ;;
    set-locale)   shift; cmd_set_locale "$@" ;;
    rollback)     shift; cmd_rollback ;;
    reboot)       shift; cmd_reboot ;;
    poweroff)     shift; cmd_poweroff ;;
    net-connect)  shift; cmd_net_connect "$@" ;;
    net-forget)   shift; cmd_net_forget "$@" ;;
    net-dhcp)     shift; cmd_net_dhcp "$@" ;;
    net-static)   shift; cmd_net_static "$@" ;;
    is-appliance) [ -e "$MARKER" ] && echo yes || echo no ;;
    *) usage; exit 2 ;;
esac
