#!/bin/bash
# List live dejima sandbox sessions and attach to one. This is the shared
# core behind both remote front doors: it runs as a forced SSH command
# (restrict,pty,command="...") and as ttyd's command. See
# docs/remote-access.md.
#
# Usage: dejima-attach [run-id|--list]
#   (no argument)  numbered picker, attaches to the choice
#   run-id         attach directly - what a notification deep link and ttyd's
#                  --url-arg produce
#   --list         one tab-separated row per live session, for scripting
set -euo pipefail

# --help before anything else runs: no libraries sourced, no config read. The
# moment someone reaches for it is often the moment the config is wrong.
# stdout and exit 0, where usage() stays on stderr and exits nonzero.
case "${1:-}" in
    -h|--help)
        cat <<'DEJIMA_ATTACH_HELP'
dejima-attach - list live dejima sessions and attach to one

Usage: dejima-attach [run-id | --list]

  (no argument)  numbered picker of live sessions; attaches to the choice
  run-id         attach to one directly, which is what a notification's deep
                 link and ttyd's --url-arg both produce
  --list         one tab-separated row per live session, for scripting

This is the shared core behind both remote front doors: it runs as a forced SSH
command and as ttyd's command, so what an SSH key can reach and what the web
terminal can reach are the same thing described once.

Attaching gives a tmux session on the HOST, not a shell inside the container.
That is deliberate - it is how an operator watches or steers a session - but it
means the tmux prefix key opens a window outside the sandbox, so exposing it is
exposing host access.

A session appears here only while its container is running.

See docs/remote-access.md.
DEJIMA_ATTACH_HELP
        exit 0 ;;
esac

SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"

if ! remote_enabled; then
    echo "Remote access is not configured (no [remote-access] enabled = on in $(config_path))." >&2
    echo "See README \"Remote access\"." >&2
    exit 1
fi

STATE_DIR="$(remote_state_dir)"
SESSIONS_DIR="$STATE_DIR/sessions"

# gruvbox dark, every value upstream's and tagged with its color.table row:
# https://github.com/gruvbox-community/gruvbox-contrib/blob/master/color.table
# The palette, the heading and the prompt come from the shared library: this
# and dejima-allow both ask an operator to pick a session, and two copies of
# that would drift apart on the terminal an operator is actually holding.
# shellcheck source=dejima-tui-lib.sh
source "$SCRIPT_DIR/dejima-tui-lib.sh"

# Narrow deliberately. The phone is this script's main front door, and the
# rule underlines the heading instead of spanning the table because the
# table's width follows the longest project name - a rule that chased it would
# wrap on a phone exactly when the rows did.
# Cleared first: this is a front door, and it is navigated to rather than
# scrolled to. dejima-allow's picker shares the banner and deliberately not
# this - see tui_clear.
banner() { tui_clear; tui_banner 'live sessions'; }

# Coloured by meaning, not by name: attachable is the one an operator wants to
# find, and stale/no-tmux are the two that will refuse if picked.
state_color() {
    case "$1" in
        attachable) printf '%s' "$C_OK" ;;
        stale)      printf '%s' "$C_ERR" ;;
        *)          printf '%s' "$C_WARN" ;;
    esac
}

# Only for a registry entry written before pid= existed, i.e. a session that
# outlived an upgrade: how long after its own started= it may still have no
# container before the prune treats it as gone. Sized against a cold first
# build, which fetches apt and npm layers and can run to minutes. Everything
# written since carries a pid and is asked directly - see launcher_alive.
PRE_PID_STARTUP_GRACE=900

# Parsed, never sourced: this file is host-written, but treating state files
# as code is the habit this repo doesn't have. Returns nonzero for an entry
# that can't be read or doesn't parse; callers skip it rather than dying,
# since one bad entry must not hide every other live session.
read_entry() {
    local file="$1" line
    entry_workspace=""; entry_pane=""; entry_label=""; entry_started=""; entry_pid=""
    [ -r "$file" ] || return 1
    while IFS= read -r line || [ -n "$line" ]; do
        case "$line" in
            workspace=*) entry_workspace="${line#*=}" ;;
            pane=*)      entry_pane="${line#*=}" ;;
            label=*)     entry_label="${line#*=}" ;;
            started=*)   entry_started="${line#*=}" ;;
            pid=*)       entry_pid="${line#*=}" ;;
        esac
    done < "$file" || return 1
    # pid reaches `kill`, which takes a job spec as well as a number: "%1" or
    # a negative value would signal something other than the launcher this
    # claims to be. Digits only, and empty is legal - see launcher_alive.
    case "$entry_pid" in
        *[!0-9]*) return 1 ;;
    esac
    # started is the one field that reaches an arithmetic context, where a
    # non-numeric value is evaluated as a *variable name* - so "started=x[$(cmd)]"
    # executes cmd, and set -u doesn't catch it when the name is already bound.
    # Refuse the entry instead of arithmetic-evaluating anything from a file.
    # (Digits alone aren't enough for the arithmetic itself - see the 10# below.)
    case "$entry_started" in
        ''|*[!0-9]*) return 1 ;;
    esac
}

# The container that reports this run ID. bin/dejima passes DEJIMA_RUN_ID in
# as env precisely so this correlation is possible without the container
# having any write access to the registry.
#
# Exit status distinguishes "no such container" (1) from "couldn't ask" (2):
# the caller prunes on the former, and pruning on the latter would permanently
# unregister every running sandbox the moment dockerd happens to be restarting.
container_for() {
    local run_id="$1" names name env unasked=""
    names="$(docker ps --filter "label=com.docker.compose.project=dejima" \
                       --filter "status=running" --format '{{.Names}}' 2>/dev/null)" || return 2
    for name in $names; do
        # Captured rather than piped straight into grep: a pipeline reports
        # only grep's status, which would turn "inspect couldn't answer for
        # this container" into "this isn't the one" - and one such container
        # anywhere in the list would prune an unrelated live session.
        env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null)" \
            || { unasked=1; continue; }
        if printf '%s\n' "$env" | grep -qxF "DEJIMA_RUN_ID=$run_id"; then
            printf '%s' "$name"
            return 0
        fi
    done
    [ -z "$unasked" ] || return 2
    return 1
}

pane_alive() {
    [ -n "$1" ] || return 1
    tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$1"
}

# Does session $1 actually hold pane $2? The only question that separates the
# session we mean from one that merely carries its name. -s enumerates every
# pane of every window in the session, and pane ids are never reused within a
# server, so a match cannot be some later pane wearing an old id. pipefail
# makes an unanswerable list-panes a "no", which is the safe direction.
group_holds_pane() {
    tmux list-panes -s -t "=$1" -F '#{pane_id}' 2>/dev/null | grep -qxF "$2"
}

# Whether the bin/dejima that registered this run is still running. Asked
# instead of guessing how long a launch takes: a cold first build (base image
# plus derived dependency images over a slow link) has no bound worth
# guessing, and the errors are not symmetric. Too long leaves a dead session
# listed. Too short unregisters a session about to start, and bin/dejima never
# rewrites its entry, so that session is permanently absent from both front
# doors.
#
# A recycled pid reads as alive and leaves an entry listed longer than it
# should be. That is the harmless direction, and the only one available: there
# is nothing to compare a pid against that a dead process still answers.
#
# An entry with no pid= was written by a bin/dejima from before this field
# existed, which can only be a session that outlived an upgrade. Those fall
# back to the window below, since pruning them on sight is the failure this
# replaced. An entry that does not parse has nothing to judge and is not
# covered: it is pruned, as before. read_entry is called again by the caller
# afterwards; re-reading five lines costs less than restructuring the loop
# around this one field.
launcher_alive() {
    local now="$2"
    read_entry "$1" || return 1
    if [ -n "$entry_pid" ]; then
        kill -0 "$entry_pid" 2>/dev/null
        return
    fi
    [ "$(( now - 10#$entry_started ))" -lt "$PRE_PID_STARTUP_GRACE" ]
}

# The registry is a hint, not truth: bin/dejima's EXIT trap doesn't run under
# SIGKILL or a host crash, so an entry can outlive its session. An entry with
# no matching container - and old enough that it should have had one - is
# pruned outright; one whose pane is gone is shown as stale rather than
# offered for attachment.
list_sessions() {
    local file run_id container state now rc warned=""
    [ -d "$SESSIONS_DIR" ] || return 0
    now="$(date +%s)"
    for file in "$SESSIONS_DIR"/*; do
        [ -f "$file" ] || continue
        run_id="$(basename "$file")"
        valid_run_id "$run_id" || continue
        rc=0
        container="$(container_for "$run_id")" || rc=$?
        if [ "$rc" -eq 1 ] && launcher_alive "$file" "$now"; then
            # Registered, but bin/dejima is still building - "not started yet"
            # is a third answer, and pruning on it would unregister a session
            # that is about to exist. Listed with the container column saying
            # so; the recorded pane is the one bin/dejima is building in, so
            # a session in this state is attachable like any other.
            container="starting"
        elif [ "$rc" -eq 1 ]; then
            rm -f "$file"
            # The stamps for a run whose registry entry outlived it -
            # bin/dejima's own cleanup() removes both together on a normal
            # exit, so this covers the exit that ran no trap. Unguarded, like
            # the rm above and unlike the rm -rf below: the debounce
            # directory is host-owned and is never mounted into a sandbox.
            rm -f "$STATE_DIR/debounce/$run_id-waiting" "$STATE_DIR/debounce/$run_id-done"
            # The marker directory is deliberately NOT removed here. It is the
            # bind source of a mount bin/dejima declares, and its lifetime
            # belongs to that process's EXIT trap; a second owner here can only
            # ever race it. Removing it under a live session is not a leak
            # being tidied but a session being broken - Docker recreates the
            # bind source root-owned, hooks/dejima-notify.sh then fails on
            # every notification for the rest of the run, and nothing
            # rewrites the registry entry to make the session visible again.
            continue
        elif [ "$rc" -ne 0 ]; then
            # An unanswerable question is never a "no": the entry stays, and
            # the container column says the daemon couldn't confirm it.
            container="?"
            [ -n "$warned" ] || echo "warning: could not query docker - sessions listed unverified." >&2
            warned=1
        fi
        if ! read_entry "$file"; then
            echo "warning: skipping unreadable or malformed registry entry: $file" >&2
            continue
        fi
        if [ -z "$entry_pane" ]; then
            state="no-tmux"
        elif pane_alive "$entry_pane"; then
            state="attachable"
        else
            state="stale"
        fi
        printf '%s\t%s\t%s\t%dm\t%s\t%s\n' \
            "$run_id" "${entry_label:-?}" "$(basename "$entry_workspace")" \
            "$(( (now - 10#$entry_started) / 60 ))" "$state" "${container:0:12}"
    done
}

# Attaches through a GROUPED session rather than the operator's own. Grouped
# sessions share windows but track their own current window, so a phone can
# sit on a different sandbox than the desktop, and destroy-unattached disposes
# of the throwaway on detach.
#
# Nothing here sets window-size, deliberately: keeping the desktop from being
# resized down to phone dimensions is what tmux's own default (latest, since
# 3.1) already does, and the option's only scopes are the window - which a
# grouped session SHARES with the operator's own, so setting it there
# permanently overwrites their choice, with nothing to restore it afterwards -
# and global. An operator who deliberately set smallest keeps it.
attach() {
    local run_id="$1" file group win base target
    file="$SESSIONS_DIR/$run_id"
    [ -f "$file" ] || { echo "No such session: $run_id" >&2; exit 1; }
    if ! read_entry "$file"; then
        echo "Session $run_id has an unreadable or malformed registry entry." >&2
        exit 1
    fi
    if [ -z "$entry_pane" ]; then
        echo "Session $run_id was not started from tmux - nothing to attach to." >&2
        exit 1
    fi
    if ! pane_alive "$entry_pane"; then
        echo "Session $run_id is stale - its tmux pane is gone." >&2
        exit 1
    fi
    group="dejima-$run_id"
    # Both values from the same live pane, one round trip. Taking the session
    # name from the recorded label would follow a name rather than the
    # sandbox, and a rename is enough to point it at an unrelated session.
    #
    # The name is a starting point, not a guarantee: it is often another
    # remote client's throwaway group, and those carry destroy-unattached, so
    # anyone with a second connection can make the base stop existing. Hence
    # the verification below - `new-session -t` does not fail on a name
    # matching nothing, it silently creates an UNGROUPED session, which for a
    # remote client is a plain host shell with no sandbox in it.
    #
    # display-message prints nothing and exits 0 for a vanished target, so an
    # empty result means the pane went away since the check above. The
    # verification catches that too; bailing early just avoids creating a
    # junk session and says what happened.
    target="$(tmux display-message -p -t "$entry_pane" '#{session_name}:#{window_index}' 2>/dev/null || true)"
    # Session names may contain ':', window indexes can't.
    base="${target%:*}"
    win="${target##*:}"
    if [ -z "$base" ] || [ -z "$win" ]; then
        echo "Session $run_id is stale - its tmux pane went away." >&2
        exit 1
    fi
    if tmux has-session -t "=$group" 2>/dev/null; then
        # Verified exactly like the session created below, and for the same
        # reason: nothing here created this one, so carrying the name is not
        # evidence of holding the sandbox. Reachable only as debris - a
        # dejima-<run-id> that exists but no longer contains the pane - and
        # anyone able to create that name already has a host shell, so this is
        # about landing in the right window rather than about containment.
        # Refused rather than killed: destroying a session this script did not
        # create is not its call, and the message names the one to look at.
        if ! group_holds_pane "$group" "$entry_pane"; then
            echo "Session $run_id: the existing tmux session $group does not hold its pane." >&2
            echo "Remove it (tmux kill-session -t '=$group') and attach again." >&2
            exit 1
        fi
        # Select within the GROUP, not via the pane id directly: the latter
        # would move the operator's own session's current window too.
        tmux select-window -t "=$group:$win"
        # Set here too, not just on the session we create: the option is
        # per-session state, so a group that ever reached this point without it
        # would keep outliving its client on every later attach.
        exec tmux attach-session -t "=$group" \; set-option destroy-unattached on
    fi
    # Created DETACHED first so the result can be inspected before a client
    # ever sees it: whatever tmux gave us has to actually contain the sandbox
    # pane, or it is a session tmux invented from a name that stopped existing
    # while we were looking at it. Anything else is killed rather than handed
    # over. ("="-anchored, so a name can only ever resolve exactly - an
    # unanchored target prefix/fnmatch-matches some other session.)
    tmux new-session -d -t "=$base" -s "$group"
    # Detached, it now outlives every failure below, and a session leaked that
    # way never gets destroy-unattached set on it, so it sits on the host for
    # good. The trap only ever names a session this call just created:
    # new-session fails on a duplicate name before we reach it.
    # ("="-anchored also so it can never mean "the current session", which is
    # what an empty target means to kill-session.)
    #
    # The name is baked in here rather than read at exit: $group is local, and
    # a trap body runs after the function's locals are gone, where set -u
    # would abort it and leak the session it exists to remove.
    # shellcheck disable=SC2064
    trap "tmux kill-session -t '=$group' 2>/dev/null || true" EXIT
    if ! group_holds_pane "$group" "$entry_pane"; then
        echo "Session $run_id is stale - the tmux session holding its pane went away." >&2
        exit 1
    fi
    tmux select-window -t "=$group:$win"
    # destroy-unattached has to be set by the same command that attaches:
    # setting it while the session is still detached makes the server reap the
    # session before the attach lands.
    #
    # Deliberately not exec'd, unlike the branch above, which creates nothing:
    # attaching is the step most likely to fail on a remote client's terms -
    # no terminal for `ssh -T`, or a $TERM the host has no entry for, both of
    # which the client picks - and an exec leaves no shell behind to clean up
    # the session we just made. Detaching normally returns 0 and disarms the
    # trap; the session then goes away by destroy-unattached, not by us.
    tmux attach-session -t "=$group" \; set-option destroy-unattached on
    trap - EXIT
}

picker() {
    local -a ids=() labels=() projects=() ages=() states=() containers=()
    local i choice run_id label project age state container
    local w_label=0 w_project=0 w_age=0 w_state=0
    # Split on tabs only, so a space in a project name stays part of one field.
    # The columns are measured on the way in because they are padded to the
    # widest value rather than to a guess: a project name is a directory name,
    # and a fixed width either truncates a long one or indents every short one
    # off the right edge of a phone.
    while IFS=$'\t' read -r run_id label project age state container; do
        ids+=("$run_id"); labels+=("$label"); projects+=("$project")
        ages+=("$age"); states+=("$state"); containers+=("$container")
        [ "${#label}" -le "$w_label" ] || w_label="${#label}"
        [ "${#project}" -le "$w_project" ] || w_project="${#project}"
        [ "${#age}" -le "$w_age" ] || w_age="${#age}"
        [ "${#state}" -le "$w_state" ] || w_state="${#state}"
    done < <(list_sessions)
    banner
    if [ "${#ids[@]}" -eq 0 ]; then
        printf '  %sNo live sessions.%s\n\n' "$C_MUTED" "$C_OFF"
        exit 0
    fi
    # The container column stays, and last, because it is not decoration: it
    # reads "starting" for a session bin/dejima is still building and "?" when
    # docker could not be asked at all, and dropping it would make either one
    # look like an ordinary attachable session.
    for i in "${!ids[@]}"; do
        local sc; sc="$(state_color "${states[$i]}")"
        printf '  %s%2d%s  %s%s%s %s%-*s%s  %s%-*s%s  %s%*s%s  %s%-*s%s  %s%s%s\n' \
            "$C_WARN" "$((i + 1))" "$C_OFF" \
            "$sc" "$GLYPH_DOT" "$C_OFF" \
            "$C_BOLD$C_TEXT" "$w_project" "${projects[$i]}" "$C_OFF" \
            "$C_LINK" "$w_label" "${labels[$i]}" "$C_OFF" \
            "$C_MUTED" "$w_age" "${ages[$i]}" "$C_OFF" \
            "$sc" "$w_state" "${states[$i]}" "$C_OFF" \
            "$C_SUBTLE" "${containers[$i]}" "$C_OFF"
    done
    tui_pick "${#ids[@]}" 'attach to'
    choice="$TUI_CHOICE"
    attach "${ids[$((choice - 1))]}"
}

case "${1:-}" in
    '')      picker ;;
    --list)  list_sessions ;;
    *)
        # This argument arrives from ttyd's --url-arg, i.e. from whoever can
        # reach the web front door. Validate its shape before it is used as a
        # path component or anything else.
        valid_run_id "$1" || { echo "Invalid session id." >&2; exit 1; }
        [ -f "$SESSIONS_DIR/$1" ] || { echo "No such session: $1" >&2; exit 1; }
        attach "$1"
        ;;
esac
