#!/usr/bin/env bash
#
# fgc — run fire_gimbal_control detached from the terminal, in a tmux session.
#
# The tower is operated over ssh. Launched directly, the program is a child of
# the ssh session's shell and dies with it — mid-scan, and with the camera torn
# down uncleanly (which wedges it for the next run). Here tmux owns the pty, so
# disconnecting is invisible to the program.
#
#   scripts/fgc start [args...]   start it detached (args go to the binary)
#   scripts/fgc attach            open the dashboard; Ctrl-b d to leave it running
#   scripts/fgc status            is it running, since when, as which PID
#   scripts/fgc stop              clean shutdown (camera + motor teardown)
#   scripts/fgc peek              print what the pane shows right now
#   scripts/fgc restart [args...] stop, then start
#
# Detaching is Ctrl-b d. NOTE: the dashboard's own `q` key *exits the program*,
# it does not detach — that is the one thing to get right when attached.
#
# Only one instance may run. The binary enforces that itself with a lockfile
# (see src/core/SingleInstance.cpp), so a bare ./build/fire_gimbal_control is
# refused too, not just a second `fgc start`.
#
# Env: FGC_SESSION overrides the tmux session name (default "fgc").
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/.." && pwd)"
session="${FGC_SESSION:-fgc}"
target="=$session"   # "=" makes tmux match the name exactly, not as a prefix

die() { echo "fgc: $*" >&2; exit 1; }

need_tmux() {
    command -v tmux >/dev/null 2>&1 || die "tmux is not installed (apt-get install tmux)"
}

session_exists() { tmux has-session -t "$target" 2>/dev/null; }

# "1" if the pane's program has exited, "0" if alive, "" if there is no session.
# remain-on-exit keeps a finished pane around, so a session existing is not on
# its own proof that the program is running.
pane_dead() { tmux list-panes -t "$target" -F '#{pane_dead}' 2>/dev/null | head -1; }

running() { [[ "$(pane_dead)" == "0" ]]; }

# PID of the pane's process: the `_run` wrapper shell, which forwards signals to
# the app and reports its exit status to tmux. For the app's own PID use
# app_pid() — the lockfile is written by the program itself.
pane_pid() { tmux list-panes -t "$target" -F '#{pane_pid}' 2>/dev/null | head -1; }

# PID of fire_gimbal_control itself, from the lockfile it writes at startup.
app_pid() {
    local f; f="$(lock_path)"
    [[ -e "$f" ]] || return 1
    local p; read -r p <"$f" 2>/dev/null || return 1
    [[ -n "$p" ]] && echo "$p"
}

# capture-pane needs a pane target; a session name alone gets "can't find pane".
# %N pane ids are unambiguous, so resolve one rather than guessing at "fgc:0.0".
pane_id() { tmux list-panes -t "$target" -F '#{pane_id}' 2>/dev/null | head -1; }

# Mirrors SingleInstance::defaultLockPath(). Kept env-independent for the same
# reason it is there: $XDG_RUNTIME_DIR is absent in a non-interactive ssh shell.
lock_path() {
    local uid; uid="$(id -u)"
    if [[ -d "/run/user/$uid" ]]; then
        echo "/run/user/$uid/fire_gimbal_control.lock"
    else
        echo "/tmp/fire_gimbal_control-$uid.lock"
    fi
}

# Is the lock actually held right now? Asked by trying to take it, not by
# checking whether the recorded PID exists: with remain-on-exit set, tmux does
# not reap the pane's process until the pane is destroyed, so a finished run
# leaves a zombie that `kill -0` happily reports as alive. The lock itself is
# the only thing that answers the question, and the kernel drops it on death.
lock_held() {
    local f="$1"
    [[ -e "$f" ]] || return 1
    ! flock -n "$f" true 2>/dev/null
}

# Vimba X needs the GenTL transport-layer dir on GENICAM_GENTL64_PATH or
# VmbStartup() fails with "Could not start Vimba X API". The SDK installs that
# via /etc/profile.d, which non-interactive ssh shells don't source. Resolving it
# *here* — i.e. inside the pane, at launch — rather than in the caller's shell
# matters: a tmux server left over from an earlier, differently-configured ssh
# session would otherwise hand the new session its stale environment.
resolve_vimba_env() {
    local s d
    # The SDK's profile.d script reads GENICAM_GENTL64_PATH before setting it, so
    # sourcing it under `set -u` aborts with "unbound variable" and the app never
    # launches. Relax nounset for the duration of the source only.
    set +u
    for s in /etc/profile.d/*Vimba*GenTL*.sh; do
        [[ -f "$s" ]] && . "$s"
    done
    set -u
    if [[ -z "${GENICAM_GENTL64_PATH:-}" ]]; then
        for d in "${VIMBA_CTI_PATH:-}" /opt/VimbaX/cti /opt/VimbaX_*/cti; do
            if [[ -n "$d" && -d "$d" ]]; then export GENICAM_GENTL64_PATH="$d"; break; fi
        done
    fi
}

cmd_start() {
    need_tmux
    if running; then
        die "already running (session '$session') — use 'fgc attach', or 'fgc restart'"
    fi
    if session_exists; then
        # A dead pane left over by remain-on-exit from a previous run. Clear it,
        # but say so — it is the only trace of how that run ended.
        echo "fgc: clearing the finished session from a previous run" >&2
        tmux kill-session -t "$target" 2>/dev/null || true
    fi

    # tmux hands the command to `sh -c`, so quote each forwarded argument rather
    # than relying on the split surviving the round trip. No `exec` here, and no
    # exec in `_run` either — see the comment there for why the wrapper shell has
    # to stay in place. `fgc stop` signals the wrapper, which forwards.
    local launch
    launch="$(printf '%q ' "$here/fgc" _run "$@")"

    # remain-on-exit=failed, not =on: a *failed* start must leave its output on
    # screen rather than destroying the session and looking like nothing
    # happened, but a clean exit must take the session with it. With =on, quitting
    # the dashboard left an operator staring at a frozen dead pane with no process
    # behind it — keys and Ctrl-C did nothing, and it read as a hung ssh session.
    #
    # It is a *window* option, and a pane that dies immediately takes the window
    # with it before the option can be set — so create the session on a
    # placeholder, set the option, then respawn the pane with the real command.
    # -c: the program resolves ./config.ini and the output dir from the cwd. The
    # pane runs this script's hidden `_run`, so the Vimba environment is resolved
    # inside the pane; run.sh finds the binary, one place only.
    tmux new-session -d -s "$session" -c "$repo" 'sleep 86400'
    # A window option needs a window target; a session name alone gets
    # "no such window". Resolve the window's own id (@N) and use that.
    local win; win="$(tmux list-windows -t "$target" -F '#{window_id}' | head -1)"
    local pane; pane="$(pane_id)"
    # "failed" needs tmux >= 3.2; fall back to "on" rather than leaving it unset,
    # where a failed start would vanish without a trace.
    tmux set-option -w -t "$win" remain-on-exit failed >/dev/null 2>&1 \
        || tmux set-option -w -t "$win" remain-on-exit on >/dev/null
    # Spell the way out along the bottom of the screen. Without it there is no
    # clue on screen that Ctrl-b d exists — and if the program dies, the pane
    # freezes with no process behind it, so keys and Ctrl-C do nothing and it
    # reads as a hung ssh session. tmux's status line stays alive either way.
    # These are *session* options, and set-option rejects the "=name" exact-match
    # form that every other subcommand here takes ("no such session: =fgc"), so
    # target the pane and let tmux resolve the session from it.
    tmux set-option -t "$pane" status on >/dev/null
    tmux set-option -t "$pane" status-style 'bg=colour24,fg=colour255' >/dev/null
    tmux set-option -t "$pane" status-left ' fire-gimbal-control ' >/dev/null
    tmux set-option -t "$pane" status-left-length 30 >/dev/null
    tmux set-option -t "$pane" status-right-length 80 >/dev/null
    tmux set-option -t "$pane" status-right \
        ' Ctrl-b d = detach, keeps running   |   q = quit the program ' >/dev/null

    tmux respawn-pane -k -t "$win" -c "$repo" "$launch" >/dev/null

    # Give it a moment to fail fast (bad config, lock already held, no camera).
    sleep 2
    local dead; dead="$(pane_dead)"
    if [[ -z "$dead" ]]; then
        # No session at all: the program exited 0 within the 2s window, so
        # remain-on-exit=failed tore it down and took the output with it.
        die "session '$session' is gone — the program exited immediately (status 0).
     Reproduce it in the foreground to see why:  scripts/run.sh $*"
    fi
    if [[ "$dead" == "1" ]]; then
        local status
        status="$(tmux list-panes -t "$target" -F '#{pane_dead_status}' | head -1)"
        echo "fgc: failed to start (exit ${status:-?}):" >&2
        tmux capture-pane -p -t "$(pane_id)" | sed '/^$/d' | tail -20 >&2
        tmux kill-session -t "$target" 2>/dev/null || true
        exit "${status:-1}"
    fi

    echo "fgc: started detached as PID $(pane_pid) (session '$session')"
    echo "     attach: scripts/fgc attach     (Ctrl-b d to detach; 'q' quits the program)"
}

# Hidden: the command tmux runs inside the pane. Not for direct use.
#
# The app is run as a CHILD and waited on, rather than exec'd. That looks like a
# pointless extra process, and it is load-bearing: when the app is itself the
# pane's process, tmux never records an exit status for it (#{pane_dead_status}
# comes back empty and the process lingers as a zombie), so remain-on-exit=failed
# cannot tell success from failure and keeps *every* finished pane — which is
# what left an operator staring at a frozen dead pane after pressing q. With a
# shell in between to reap the child and exit with its status, tmux gets a real
# status and tears the session down on a clean quit. Measured: 3/3 kept when
# exec'd, 3/3 clean when parented.
cmd__run() {
    resolve_vimba_env

    # <&0 is required: bash redirects a background command's stdin from
    # /dev/null unless it is given one explicitly, which would leave the
    # dashboard unable to read a single keystroke.
    "$here/run.sh" "$@" <&0 &
    local child=$!

    # Forward the signals `fgc stop` and a pane kill use, so the app still runs
    # its own clean teardown (stop capture, close camera, save exposure store)
    # instead of being killed under the wrapper.
    trap 'kill -TERM "$child" 2>/dev/null' TERM
    trap 'kill -INT  "$child" 2>/dev/null' INT
    trap 'kill -HUP  "$child" 2>/dev/null' HUP

    # A trapped signal makes `wait` return 128+n while the child is still
    # shutting down, so keep waiting until it is actually gone before reporting.
    local rc=0
    wait "$child"; rc=$?
    while kill -0 "$child" 2>/dev/null; do
        wait "$child"; rc=$?
    done
    exit "$rc"
}

cmd_attach() {
    need_tmux
    running || die "not running — start it with 'fgc start'"
    if [[ -n "${TMUX:-}" ]]; then
        die "already inside tmux; use 'tmux switch-client -t $session'"
    fi
    echo "fgc: attaching — Ctrl-b d detaches and leaves it running ('q' would quit it)" >&2
    exec tmux attach-session -t "$target"
}

cmd_status() {
    need_tmux
    local lock; lock="$(lock_path)"
    if ! running; then
        if session_exists; then
            echo "fgc: not running — session '$session' holds a finished pane:"
            tmux capture-pane -p -t "$(pane_id)" | sed '/^$/d' | tail -10 | sed 's/^/     /'
            echo "     clear it with 'fgc stop' (or just 'fgc start')"
        else
            echo "fgc: not running (no tmux session '$session')"
        fi
        # The lock outliving the session means an instance is running that fgc
        # did not start — systemd, or someone's bare ./fire_gimbal_control.
        if lock_held "$lock"; then
            local pid; read -r pid <"$lock" || pid="?"
            echo "     but $lock is held by PID $pid — an instance is running outside tmux"
            return 0
        fi
        return 1
    fi
    local app; app="$(app_pid || echo "$(pane_pid)")"
    echo "fgc: running (session '$session', PID $app)"
    ps -o lstart=,etime=,args= -p "$app" 2>/dev/null | sed 's/^/     /'
    echo "     lockfile: $lock"
}

cmd_stop() {
    need_tmux
    if ! running; then
        if session_exists; then
            tmux kill-session -t "$target" 2>/dev/null || true
            echo "fgc: cleared the finished session '$session'"
        else
            echo "fgc: not running"
        fi
        return 0
    fi
    local pid; pid="$(pane_pid)"

    # The pane should be our `_run` wrapper, which forwards the signal to the
    # app. Signalling something else would look like a clean stop while the
    # program kept running, so check rather than assume.
    if ! ps -o args= -p "$pid" 2>/dev/null | grep -qE 'fgc _run|fire_gimbal'; then
        echo "fgc: pane process $pid is not the fgc launcher:" >&2
        ps -o args= -p "$pid" 2>/dev/null | sed 's/^/     /' >&2
        die "refusing to signal it — attach and shut it down by hand"
    fi

    # SIGTERM, not a keystroke: Application installs a SIGTERM handler that runs
    # the same clean teardown as the `exit` command (stop capture, close the
    # camera, save the exposure store, park the motor), and unlike sending keys
    # it works the same in TUI and headless mode. Never SIGKILL — an unclean
    # exit leaves the camera wedged for the next run.
    echo "fgc: stopping fire_gimbal_control (PID $(app_pid || echo '?')) via SIGTERM…"
    kill -TERM "$pid" 2>/dev/null || true

    # Wait on tmux's view of the pane, not on `kill -0`: while a pane is kept
    # after exit, tmux holds the finished process as a zombie until the pane is
    # destroyed, so `kill -0` would report it alive forever and every stop would
    # "time out". `running` covers both endings — a clean exit destroys the
    # session outright, a failed one leaves the pane behind with dead=1.
    #
    # 120s, because shutdown is not instant: the control loop only notices the
    # flag between ticks, and a capture attempt that is timing out on the camera
    # (3 tries x 2s) holds it for several seconds before that.
    local i
    for i in $(seq 1 120); do
        running || break
        sleep 1
    done
    if running; then
        echo "fgc: PID $pid still running 120s after SIGTERM — attach and look at why;" >&2
        echo "     do NOT kill -9, that wedges the camera." >&2
        return 1
    fi
    tmux kill-session -t "$target" 2>/dev/null || true
    echo "fgc: stopped"
}

cmd_peek() {
    need_tmux
    running || die "not running"
    # In TUI mode FTXUI draws on the alternate screen, so this is a snapshot of
    # the live dashboard; in headless mode it is the log scrollback.
    tmux capture-pane -p -S - -t "$(pane_id)"
}

cmd_restart() {
    cmd_stop
    cmd_start "$@"
}

sub="${1:-}"
[[ $# -gt 0 ]] && shift
case "$sub" in
    start)    cmd_start "$@" ;;
    attach)   cmd_attach ;;
    status)   cmd_status ;;
    stop)     cmd_stop ;;
    peek|logs) cmd_peek ;;
    restart)  cmd_restart "$@" ;;
    _run)     cmd__run "$@" ;;
    ""|-h|--help|help)
        # The header block above, minus the shebang: one source of truth.
        awk 'NR>2 && /^#/ {sub(/^# ?/, ""); print; next} NR>2 {exit}' "${BASH_SOURCE[0]}"
        [[ -z "$sub" ]] && exit 1 || exit 0 ;;
    *) die "unknown command: $sub (try 'fgc --help')" ;;
esac
