#!/usr/bin/env bash
# booth — post media and links to The Booth (dead simple). A booth is just a
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
# back.
#
#   booth new    <name>                 make an empty booth, print its URL
#   booth add    <name> <file>...       copy files into a booth (creates it), print URL
#   booth url    <name>                 print a booth's URL
#   booth ls                            list booths (kept ones marked ★)
#   booth rm     <name>                 wipe a booth now (TTL would eventually anyway)
#
#   booth keep   <name>                 exempt a booth from the 24h sweep, forever
#   booth unkeep <name>                 hand it back to the sweeper
#   booth link   <url> [description]    append a link to the standing link board
#   booth links                         list the board, numbered, with entry ids
#   booth unlink <id|index>             remove ONE link from the board
#
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
# activity — that is the contract, and it is why nobody has to clean up after
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
# sweep and moves it into its own lane at the top of the index. Use it for
# durable operator-facing boards, not for run output. `unkeep` is just `rm` of
# the sentinel, so putting a board back under the sweeper costs nothing.
#
# DELETING A KEPT BOARD: `booth rm <name>` works on kept boards too and deletes
# NOW — it announces that the board was kept, so wiping something durable is
# never silent. In the web UI it is two deliberate steps: `release` on the kept
# card drops the sentinel, the card moves to the ephemeral lane, and the × wipes
# it from there.
#
# DO NOT "unkeep and let it expire". Removing the sentinel BUMPS the booth
# directory's mtime, and a booth's age is the newest mtime in its tree — so a
# released board's clock RESETS and it survives another full 24h. Unkeep-and-wait
# is a delay, not a delete. Use `rm` (or the UI ×) when you mean now.
#
# `link` is the reason the exception exists: agent sessions hand the operator
# URLs that then drown in terminal scrollback. They go on a standing kept board
# instead, with provenance, so they outlive the session that produced them.
#
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
#   rsync -a ./out/  nh3-dev:booth-data/my-run/
set -euo pipefail

DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
URL="${BOOTH_URL:-http://10.100.10.50:8090}"
KEEP=".forever"                                    # must match KEEP_MARKER in booth/app.py
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"

usage() {
  echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]|links|unlink <id|index>}" >&2
  exit 2
}

cmd="${1:-}"; shift || true
case "$cmd" in
  new)
    [ $# -ge 1 ] || usage
    mkdir -p -- "$DATA/$1"
    echo "$URL/b/$1/"
    ;;
  add)
    [ $# -ge 2 ] || usage
    name="$1"; shift
    mkdir -p -- "$DATA/$name"
    cp -- "$@" "$DATA/$name/"
    echo "$URL/b/$name/"
    ;;
  url)
    [ $# -ge 1 ] || usage
    echo "$URL/b/$1/"
    ;;
  ls)
    [ -d "$DATA" ] || exit 0
    for d in "$DATA"/*/; do
      [ -d "$d" ] || continue
      n="$(basename -- "$d")"
      if [ -e "$d$KEEP" ]; then echo "★ $n"; else echo "  $n"; fi
    done
    ;;
  rm)
    [ $# -ge 1 ] || usage
    # Say so when the thing destroyed was durable. Not a block — a CLI user
    # naming a booth is being explicit — but a kept board disappearing must not
    # look identical to run output disappearing.
    was_kept=""
    [ -e "$DATA/$1/$KEEP" ] && was_kept=" (was KEPT — durable board)"
    rm -rf -- "${DATA:?}/$1"
    echo "wiped $1$was_kept"
    ;;
  keep)
    [ $# -ge 1 ] || usage
    [ -d "$DATA/$1" ] || { echo "no such booth: $1" >&2; exit 1; }
    : > "$DATA/$1/$KEEP"
    echo "kept (exempt from the sweep): $URL/b/$1/"
    ;;
  unkeep)
    [ $# -ge 1 ] || usage
    rm -f -- "$DATA/$1/$KEEP"
    echo "unkept — $1 rejoins the 24h sweep"
    ;;
  link)
    [ $# -ge 1 ] || usage
    link_url="$1"; shift
    desc="${*:-}"
    board="$DATA/$LINKS_BOARD"
    mkdir -p -- "$board"
    : > "$board/$KEEP"                             # the board is durable by definition
    # Provenance, because a bare URL is unreadable three days later: who posted
    # it, from where, and when.
    who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
    when="$(date '+%Y-%m-%d %H:%M')"
    # ONE printf of ONE line. A single write under PIPE_BUF to an O_APPEND fd is
    # atomic on POSIX, so concurrent sessions cannot interleave a line — which
    # matters here precisely because many agents post to one board.
    # flock on the same sidecar the Python remover uses. The append is
    # atomic by itself, but `unlink` does read-modify-write, and without a
    # shared lock this line could land inside that window and be rewritten
    # away by the prune.
    touch -- "$board/.links.lock"
    flock "$board/.links.lock" \
    printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \
      "${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
    echo "$URL/b/$LINKS_BOARD/"
    ;;
  links)
    board="$DATA/$LINKS_BOARD/links.md"
    [ -f "$board" ] || { echo "no link board yet"; exit 0; }
    # The id is the same content hash the web UI and `unlink` use, so a row can
    # be named unambiguously even while other sessions are appending to the board.
    n=0
    while IFS= read -r line; do
      case "$line" in "- ["*) ;; *) continue ;; esac
      n=$((n+1))
      id="$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sha1sum | cut -c1-8)"
      printf '%3d  %s  %s\n' "$n" "$id" "$line"
    done < "$board"
    # `if`, NOT `[ ... ] && echo`: as the LAST statement of the branch that
    # idiom returns 1 whenever the board is non-empty, so `booth links` exits
    # non-zero on success — and `unlink`'s index lookup, which calls it inside
    # $( ) under `set -e`, then dies silently.
    if [ "$n" -eq 0 ]; then echo "board has no link rows"; fi
    ;;
  unlink)
    [ $# -ge 1 ] || usage
    board="$DATA/$LINKS_BOARD"
    [ -f "$board/links.md" ] || { echo "no link board" >&2; exit 1; }
    target="$1"
    # A bare number is accepted for convenience but resolved to the row's
    # CONTENT ID before anything is deleted: between `booth links` and
    # `booth unlink` another session may have appended, and deleting by POSITION
    # would then take the wrong row. An id either matches the row you saw or
    # matches nothing.
    # DISAMBIGUATE BY SHAPE, not by "is it numeric". A content id is exactly 8
    # hex chars, and roughly one id in forty is all digits — those were being
    # read as row numbers and silently resolving to nothing. Match the id's
    # actual shape first; anything else numeric is an index.
    case "$target" in
      [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
        ;;                                   # already a content id
      ''|*[!0-9]*)
        echo "not an entry id (8 hex chars) or a row number: $target" >&2; exit 1 ;;
      *)
        target="$("$0" links | awk -v n="$target" '$1==n{print $2}')"
        [ -n "$target" ] || { echo "no row $1 on the board" >&2; exit 1; } ;;
    esac
    # `|| exit 1` so a failure is reported rather than swallowed; `set -e` inside
    # a command substitution elsewhere in this script has bitten us already.
    BOOTH_SRC="$(cd "$(dirname -- "$0")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.links import remove_link_entry  # stdlib only — no venv needed
removed = remove_link_entry(pathlib.Path(sys.argv[1]), sys.argv[2])
if removed is None:
    sys.exit("no such entry: %s (already removed?)" % sys.argv[2])
print("removed: %s  %s" % (removed["desc"], removed["url"]))
' "$board" "$target"
    ;;
  *) usage ;;
esac
