#!/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
#
#   booth ask    <name> <id> <prompt> <option>... [--no-notes]
#                                       pose a multiple-choice question in a booth
#   booth marks  <name> [--wait [SECS]] print every mark in a booth as JSON;
#                                       --wait blocks while any pick is still open
#   booth answer <name> <id> [--wait [SECS]]
#                                       print ONE pick's answer (exit 1 if unanswered);
#                                       --wait polls until it lands (default 3600 s)
#
# EXIT CODES for the two reading verbs. A read that FAILED gets its own code so
# a caller can tell "not yet" from "the file is damaged" — conflating them is
# how a broken `.marks.json` used to look like an unanswered question and wait
# out the full hour.
#   marks   0 read ok · 1 --wait timed out with picks open · 3 unreadable
#   answer  0 answered · 1 unanswered · 2 no such pick · 3 unreadable
#   booth marks-import <name>           import legacy *.ask.json into .marks.json
#   booth asks   <name>                 alias for `marks` (deprecated)
#
# MARKS. One primitive for operator judgment attached to an artifact:
#   pick — one of N options a session declared in advance   (this is `ask`)
#   note — free text the operator volunteered
#   flag — the operator pointing at one item
# All three are written by the OPERATOR IN THE BROWSER and read by the session.
# There are no `note` / `flag` verbs here on purpose: this CLI is the session's
# side of the loop, and a session does not author the operator's judgment.
#
# A session needs the operator to pick one of N things — which render, which
# plan, go/no-go — and act on the pick. `ask` declares it; the page renders a
# radio form with a notes field; submitting records the judgment. `answer --wait`
# blocks until it lands and prints it, so a session can
# `booth ask … && booth answer --wait …` and carry on. Re-answering overwrites:
# a mark is the CURRENT judgment, not a log. Several questions in ONE form: pass
# a `questions` list (see README § Asks); every verb handles both shapes.
#
# Marks live in ONE file per booth, `<booth>/.marks.json`, so "does this booth
# still owe an answer?" is a single read. Remote sessions have no filesystem
# access, so they poll the HTTP mirror instead:
#   http://10.100.10.50:8090/b/<name>/marks.json
#
# 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
BLUR=".blurred"   # one booth-relative item path per line; see `blur` below
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"

usage() {
  echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&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"
    ;;
  blur|unblur)
    # ⚠ COSMETIC ONLY. A blurred item is still served at its own URL, still in
    # the zip, still on disk. This hides it from a glance — a shoulder, a
    # screen-share, a scroll past something you did not want full-size. The
    # Booth has no auth by design: if a thing must not be SEEN, it must not be
    # in a booth.
    [ $# -ge 2 ] || usage
    b="$1"; shift
    [ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; }
    f="$DATA/$b/$BLUR"
    for item in "$@"; do
      item="${item#"$DATA/$b/"}"; item="${item#/}"
      case "$item" in
        *..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
      esac
      [ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
      touch "$f"
      if [ "$cmd" = blur ]; then
        grep -qxF -- "$item" "$f" || printf '%s\n' "$item" >> "$f"
      else
        grep -vxF -- "$item" "$f" > "$f.tmp" || true
        mv -- "$f.tmp" "$f"
      fi
    done
    # An empty marker is a lie by omission — `ls -a` should say whether
    # anything here is blurred at all.
    [ -s "$f" ] || rm -f -- "$f"
    if [ "$cmd" = blur ]; then
      echo "blurred (cosmetic — still served): $URL/b/$b/"
    else
      echo "un-blurred: $URL/b/$b/"
    fi
    ;;
  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 -- "$(readlink -f -- "$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"
    ;;
  ask)
    # booth ask <name> <id> <prompt> <opt>... [--no-notes]
    [ $# -ge 5 ] || usage
    name="$1"; mid="$2"; prompt="$3"; shift 3
    notes=1; opts=()
    for a in "$@"; do
      case "$a" in --no-notes) notes=0 ;; *) opts+=("$a") ;; esac
    done
    [ "${#opts[@]}" -ge 2 ] || { echo "a pick needs at least 2 options" >&2; exit 1; }
    # Validated through the SAME normaliser the page uses, so a session cannot
    # declare a question the renderer would refuse. stdlib only — no venv needed.
    BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import AskError
from booth.marks import MarksCorrupt, declare_pick
booth, mid, prompt, *opts = sys.argv[1:]
try:
    declare_pick(pathlib.Path(booth), mid,
                 {"prompt": prompt, "options": opts,
                  "notes": os.environ["ASK_NOTES"] == "1"})
except AskError as exc:
    sys.exit("bad pick: %s" % exc)
except MarksCorrupt as exc:
    sys.exit("this booth'"'"'s .marks.json is damaged, so nothing was written: %s" % exc)
' "$DATA/$name" "$mid" "$prompt" "${opts[@]}"
    echo "$URL/b/$name/#mark-$mid"
    ;;
  marks|asks)
    # booth marks <name> [--wait [SECS]]     (`asks` is the deprecated alias)
    #
    # EXIT CODES. 0 = the read succeeded and the document is on stdout; 1 =
    # --wait gave up with picks still open (the document is still printed); 3 =
    # the marks could not be read at all. A reader that CRASHED must never look
    # like an answer — the old shape printed a traceback and exited 0, so a
    # caller piping to `jq` saw success and got nothing.
    #
    # Whether anything is still open is in the payload's `open` list. The read
    # verb does not encode it in its status: a successful read is a success.
    [ $# -ge 1 ] || usage
    name="$1"; shift
    wait_s=0
    if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
    # Poll, do not inotify: the judgment is written by a different process via
    # os.replace, and a 2 s cadence is plenty for a human clicking a radio.
    deadline=$(( $(date +%s) + wait_s ))
    while :; do
      # CAPTURED, not streamed. Printing inside the loop wrote one whole JSON
      # document per poll, so `booth marks b --wait | jq` got several values
      # concatenated and could parse none of them. The wait is a wait; the
      # print is the result, and it happens once.
      rc=0
      out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import json, os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
    from booth.marks import as_dict, marks_for, open_marks, read_error
    booth = pathlib.Path(sys.argv[1])
    # Ask FIRST whether the file is readable. `marks_for` answers "no marks"
    # for a damaged file, which is the right answer for a page and the wrong
    # one for a session that wants to know whether its question survived.
    broken = read_error(booth)
    if broken:
        print(f"booth: {broken}", file=sys.stderr)
        sys.exit(3)
    marks = marks_for(booth)
    doc = json.dumps({"marks": [as_dict(m) for m in marks],
                      "open": [m.id for m in open_marks(marks)]},
                     ensure_ascii=False, indent=2)
except Exception as exc:
    print(f"booth: cannot read marks: {exc}", file=sys.stderr)
    sys.exit(3)
print(doc)
sys.exit(2 if open_marks(marks) else 0)
' "$DATA/$name")" || rc=$?
      case "$rc" in
        0) printf '%s\n' "$out"; exit 0 ;;                 # read ok, nothing open
        2) if [ "$wait_s" -eq 0 ]; then printf '%s\n' "$out"; exit 0; fi ;;
        *) echo "cannot read marks in $name" >&2; exit 3 ;;
      esac
      if [ "$(date +%s)" -ge "$deadline" ]; then
        printf '%s\n' "$out"
        echo "timed out after ${wait_s}s with marks still open in $name" >&2; exit 1
      fi
      sleep 2
    done
    ;;
  answer)
    # booth answer <name> <id> [--wait [SECS]]
    [ $# -ge 2 ] || usage
    name="$1"; mid="$2"; shift 2
    wait_s=0
    if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
    deadline=$(( $(date +%s) + wait_s ))
    while :; do
      rc=0
      out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import json, os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
    from booth.marks import marks_for, read_error
    booth, mid = sys.argv[1:3]
    broken = read_error(pathlib.Path(booth))
    if broken:
        print(f"booth: {broken}", file=sys.stderr)
        sys.exit(3)
    # id AND shape, matching the web route. Matching on id alone reported a
    # note id as "unanswered" and then polled it for an hour — a question that
    # could never be answered because it was never a question.
    m = next((x for x in marks_for(pathlib.Path(booth))
              if x.id == mid and x.shape == "pick"), None)
except Exception as exc:
    print(f"booth: cannot read marks: {exc}", file=sys.stderr)
    sys.exit(3)
if m is None:
    sys.exit(2)
if m.answer is None:
    sys.exit(1)
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
' "$DATA/$name" "$mid")" || rc=$?
      case "$rc" in
        0) printf '%s\n' "$out"; exit 0 ;;
        2) echo "no such pick: $name/$mid" >&2; exit 2 ;;
        # A read that FAILED is not "not yet". Conflating them sent --wait
        # spinning for the full hour on a broken file and then blamed the
        # operator for not answering.
        3) echo "cannot read marks in $name" >&2; exit 3 ;;
      esac
      if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
      if [ "$(date +%s)" -ge "$deadline" ]; then
        echo "timed out after ${wait_s}s waiting on $name/$mid" >&2; exit 1
      fi
      sleep 2
    done
    ;;
  marks-import)
    # booth marks-import <name> — idempotent, and it deletes nothing
    [ $# -ge 1 ] || usage
    BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.marks import import_legacy_asks
made = import_legacy_asks(pathlib.Path(sys.argv[1]))
if not made:
    print("nothing to import (or already imported)")
for m in made:
    print("imported %-24s %s" % (m.id, m.error or ("answered" if m.answer else "open")))
' "$DATA/$1"
    ;;
  *) usage ;;
esac
