#!/usr/bin/env bash
#
# panama – Helper for managing the Panama dotfiles/repo
# Version 1.0
# Author: Gabriel Brown
#
# Commands:
#   update     Bring this machine up to date: pull, then install --upgrade
#   sync       Review, commit & push local changes to this repo
#   edit       Open the Panama repo in Neovim
#   doctor     Report what is actually running on this machine
#   diagnose   Hand this machine's health and recent errors to your agent
#   test       Run contracts classified by tests/contracts.manifest
#   contracts  Name the contracts that mention a given file
#   upgrade    Re-run the installer from anywhere, interview included
#   migrate    Apply repairs this machine has not had yet
#   apps       Choose applications to install, by category
#   app        Build and install an application that no repository packages
#   help       Show this help
#
# update and sync are deliberately separate verbs. One acts on the machine, the
# other on the repository, and a single command that guessed between them by
# looking at whether the tree happened to be dirty would do a different job
# depending on state nobody can see.
#
# Designed to grow: add new subcommands as cmd_<name> functions and
# register them in the dispatcher / usage block below.

set -euo pipefail

PROGRAM=$(basename "$0")
VERSION="1.0"

# ----------------------------------------------------------------------------
# Locate the Panama repo (this script lives in <repo>/bin/panama)
# ----------------------------------------------------------------------------
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)

# ----------------------------------------------------------------------------
# Pretty output
# ----------------------------------------------------------------------------
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
  BOLD=$(tput bold); RESET=$(tput sgr0)
  RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3)
  BLUE=$(tput setaf 4); MAGENTA=$(tput setaf 5); CYAN=$(tput setaf 6)
else
  BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; MAGENTA=""; CYAN=""
fi

info()  { printf '%s==>%s %s\n'  "${BLUE}${BOLD}"   "$RESET" "$*"; }
ok()    { printf '%s✓%s %s\n'    "${GREEN}${BOLD}"  "$RESET" "$*"; }
warn()  { printf '%s!%s %s\n'    "${YELLOW}${BOLD}" "$RESET" "$*"; }
err()   { printf '%s✗%s %s\n'    "${RED}${BOLD}"    "$RESET" "$*" >&2; }
header(){ printf '\n%s%s%s\n'    "${MAGENTA}${BOLD}" "$*" "$RESET"; }

# Ask a yes/no question. Returns 0 for yes, 1 for no. Default = no.
confirm() {
  local prompt="$1" reply
  printf '%s?%s %s %s[y/N]%s ' "${CYAN}${BOLD}" "$RESET" "$prompt" "$BOLD" "$RESET" >&2
  read -r reply || true
  [[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]]
}

# ----------------------------------------------------------------------------
# Usage
# ----------------------------------------------------------------------------
usage() {
  cat <<EOF
${BOLD}$PROGRAM${RESET} – manage the Panama repo (${PANAMA_DIR})

${BOLD}Usage:${RESET}
  $PROGRAM <command> [options]

${BOLD}Commands:${RESET}
  ${GREEN}update${RESET}      Bring this machine up to date. Pulls, then runs the stages
              that need no questions asked. The routine command; safe to
              re-run, and it never asks you anything.
  ${GREEN}sync${RESET}        Review, commit & push your changes to this repo. Uncommitted
              work is committed before anything is fetched, so a moved
              upstream is a rebase rather than a stash conflict.
  ${GREEN}edit${RESET}        Open the Panama repo in Neovim.
  ${GREEN}doctor${RESET}      Report what is actually running on this machine, rather
              than what was installed. Takes --summary for one line per check.
  ${GREEN}diagnose${RESET}    Hand the health summary, the recent journal errors and
              whatever you say is wrong to your coding agent, in a terminal.
              Needs an agent chosen on Settings › System › Agents.
  ${GREEN}test${RESET}        Run contracts classified by tests/contracts.manifest. Give it a
              pattern to run a subset. --safe selects hermetic contracts only.
              Plain terminal runs prompt before non-hermetic work. Automation
              must grant each required capability with a repeatable --allow.
              Each non-hermetic contract announces its exact capabilities
              before it starts.
              Failures print captured stdout/stderr. Successful stdout stays
              quiet; successful stderr is a warning. The default outer timeout
              is 180 seconds. Set PANAMA_TEST_TIMEOUT_SECONDS to a positive
              integer to override it.
  ${GREEN}contracts${RESET}   Name the contracts that mention a given file, each labeled
              with manifest capabilities. A heuristic over the text of tests/, so it
              answers "what should I run" rather than "what covers this".
  ${GREEN}upgrade${RESET}     Re-run ./install from anywhere, interview and all. For a new
              machine, or to change an answer you gave. Routine updates are
              '$PROGRAM update', which asks nothing.
  ${GREEN}migrate${RESET}     Apply repairs this machine has not had yet. The half of an
              upgrade that ./install cannot do, because installing only ever
              adds. Safe to re-run; nothing is applied twice.
  ${GREEN}apps${RESET}        Choose applications to install: pick a category, then tick
              what you want. The same catalog ./install offers, minus the
              install.
  ${GREEN}app${RESET}         Build and install an application that neither dnf nor
              Flathub carries. With no name, lists what is available.
  ${GREEN}server${RESET}      The compose services a server machine runs: list, enable,
              disable, status, relink. See 'panama server help'.
  ${GREEN}help${RESET}        Show this help (also -h, --help).

${BOLD}Options:${RESET}
  -h, --help      Show this help and exit
      --version   Show version and exit

${BOLD}Examples:${RESET}
  $PROGRAM update
  $PROGRAM update --packages
  $PROGRAM sync
  $PROGRAM edit
  $PROGRAM doctor --summary
  $PROGRAM diagnose
  $PROGRAM diagnose the bar disappears after unplugging the monitor
  $PROGRAM test dock
  $PROGRAM test --safe
  $PROGRAM test --allow live-host updates
  $PROGRAM test --allow live-compositor keybinds
  PANAMA_TEST_TIMEOUT_SECONDS=300 $PROGRAM test --safe
  $PROGRAM contracts config/dot/quickshell/services/Displays.qml
  $PROGRAM upgrade
  $PROGRAM apps
  $PROGRAM app
  $PROGRAM app chatgpt-desktop
EOF
}

# ----------------------------------------------------------------------------
# Guard: make sure we're in a git repo
# ----------------------------------------------------------------------------
require_git_repo() {
  if ! git -C "$PANAMA_DIR" rev-parse --git-dir >/dev/null 2>&1; then
    err "'$PANAMA_DIR' is not a git repository."
    exit 1
  fi
}

# ----------------------------------------------------------------------------
# Command: update
# ----------------------------------------------------------------------------
#
# The routine command: bring THIS MACHINE up to date. Pull, then hand the rest
# to `install --upgrade`, which asks nothing.
#
# Most of an update needs no stage at all. Every dotfile is a symlink into this
# checkout, so an edit to an existing config/dot/** file is live the moment the
# pull returns -- there is nothing to apply. Stages earn their place when a pull
# brings something structural: a new dotfile directory to link, a file under
# config/copy/ to place as root, a new package, a gsettings change. Running the
# cheap ones every time is idempotent and takes seconds; working out which were
# needed is guesswork with a silent failure mode.
#
# Uncommitted work never blocks an update. It is stashed across the pull and
# restored afterwards -- and if restoring conflicts, the tree is reset rather
# than left holding conflict markers, because on this repository those markers
# are not something you fix at your leisure. They are live in ~/.config the
# instant they are written, and a half-merged .qml is a shell that will not
# parse.
cmd_update() {
  require_git_repo
  cd "$PANAMA_DIR"

  info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"

  local stashed=0 conflict_stash=""
  if [[ -n "$(git status --porcelain)" ]]; then
    info "Local changes; stashing them across the pull."
    if git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null; then
      stashed=1
    else
      err "Could not stash local changes, so the pull would overwrite them."
      exit 1
    fi
  fi

  # --ff-only on purpose. A diverged branch is something to resolve
  # deliberately, not something an update command should merge on your behalf.
  # It is a warning rather than an error: the stages below are still worth
  # running against whatever is checked out.
  if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
    info "Pulling..."
    if git pull --ff-only; then
      ok "Checkout is current."
    else
      warn "Could not fast-forward — continuing with what is checked out."
    fi
  else
    warn "No upstream configured for this branch; nothing to pull."
  fi

  if (( stashed )); then
    if git stash pop >/dev/null 2>&1; then
      ok "Local changes restored."
    else
      # A conflicted pop keeps the stash entry -- git says so itself, and the
      # contract proves it -- so resetting here loses nothing. The work stays
      # in the stash, where nothing is reading it, instead of in your live
      # config as merge markers.
      git reset --hard HEAD >/dev/null 2>&1
      conflict_stash="$(git stash list --format='%gd: %gs' 2>/dev/null | head -1)"
      warn "Your local changes conflict with what was pulled; they stay stashed."
    fi
  fi

  local installer="$PANAMA_DIR/install"
  if [[ ! -x "$installer" ]]; then
    err "The installer is missing from $installer"
    exit 1
  fi

  local rc=0
  "$installer" --upgrade "$@" || rc=$?

  # Repeated at the very end rather than only where it happened. A warning
  # printed before twenty minutes of dnf output is a warning nobody read.
  if [[ -n "$conflict_stash" ]]; then
    echo
    warn "Your local changes were NOT restored — they conflicted with the pull."
    printf '  They are safe at %s%s%s\n' "$BOLD" "$conflict_stash" "$RESET"
    printf '  Restore them with: %sgit stash pop%s\n' "$BOLD" "$RESET"
  fi

  return $rc
}

# ----------------------------------------------------------------------------
# Command: sync
# ----------------------------------------------------------------------------
#
# The other half of what `update` used to mean: commit and push MY EDITS.
# Panama is a working tree people edit in place -- every dotfile is a symlink
# into it -- so "I changed something, put it upstream" is a daily action and
# deserves its own verb rather than sharing one with "update my machine".
#
# The commit happens BEFORE anything is fetched, which is why there is no stash
# in here. By the time upstream is consulted the work is a commit, so a moved
# upstream is a rebase over committed history -- recoverable, ordinary, and
# nothing like a stash pop conflicting into a live config.
cmd_sync() {
  require_git_repo
  cd "$PANAMA_DIR"

  info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"

  if [[ -z "$(git status --porcelain)" ]]; then
    ok "Nothing to commit — the working tree is clean."
    info "To update this machine, run: ${BOLD}${PROGRAM} update${RESET}"
    return
  fi

  header "Changed files"
  git -c color.status=always status --short

  header "Diff"
  # Tracked changes (staged + unstaged) relative to the last commit.
  git --no-pager -c color.diff=always diff HEAD

  # Untracked files won't appear in 'git diff', so show them as new files.
  local untracked
  untracked=$(git ls-files --others --exclude-standard)
  if [[ -n "$untracked" ]]; then
    while IFS= read -r f; do
      [[ -z "$f" ]] && continue
      git --no-pager -c color.diff=always diff --no-index -- /dev/null "$f" || true
    done <<< "$untracked"
  fi

  echo
  if ! confirm "Commit these changes?"; then
    warn "Aborted — no changes committed."
    return
  fi

  local msg
  printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET"
  read -r msg || true
  if [[ -z "${msg// }" ]]; then
    msg="Update $(date '+%Y-%m-%d %H:%M:%S')"
    warn "No message given — using: ${BOLD}${msg}${RESET}"
  fi

  info "Committing changes..."
  git add -A
  git commit -m "$msg"
  ok "Committed: ${BOLD}${msg}${RESET}"

  if ! git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
    warn "No upstream configured for this branch — committed locally only."
    return
  fi

  # Only now, with the work safely committed, is it worth looking upstream.
  info "Checking whether upstream has moved..."
  git fetch --quiet
  local remote_rev base_rev
  remote_rev=$(git rev-parse '@{u}')
  base_rev=$(git merge-base @ '@{u}')

  if [[ "$base_rev" != "$remote_rev" ]]; then
    warn "Upstream has moved — rebasing your commit onto it."
    if ! git pull --rebase; then
      err "The rebase stopped on a conflict."
      err "Resolve it, then: git rebase --continue"
      exit 1
    fi
    ok "Rebased onto upstream."
  else
    ok "Upstream has not moved."
  fi

  echo
  if confirm "Push the changes now?"; then
    info "Pushing..."
    if git push; then
      ok "Pushed to remote."
    else
      err "git push failed."
      exit 1
    fi
  else
    info "Done — changes committed locally but not pushed."
  fi
}

# ----------------------------------------------------------------------------
# Command: edit
# ----------------------------------------------------------------------------
cmd_edit() {
  if ! command -v nvim >/dev/null 2>&1; then
    err "Neovim (nvim) is not installed or not on PATH."
    exit 1
  fi
  info "Opening ${BOLD}${PANAMA_DIR}${RESET} in Neovim."
  cd "$PANAMA_DIR"
  exec nvim .
}

# ----------------------------------------------------------------------------
# Command: doctor
# ----------------------------------------------------------------------------
#
# The health check already exists and the installer already runs it; what it did
# not have was a way to reach it from a terminal. Everything is passed straight
# through, so --summary and anything added later work without this knowing about
# them.
cmd_doctor() {
  local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor"
  if [[ ! -x "$doctor" ]]; then
    err "panama-doctor is missing from $doctor"
    exit 1
  fi
  exec "$doctor" "$@"
}

# ----------------------------------------------------------------------------
# Command: diagnose
# ----------------------------------------------------------------------------
#
# The by-hand rung of the escalation ladder. Every other rung starts from an
# event -- a crash, a failed reload, a red check -- and this one starts from a
# person who can tell that something is wrong but not what.
#
# It gathers the two things anybody would be asked for first anyway (what the
# health check says, what the journal has been complaining about) and whatever
# words follow the command, then hands the lot to the configured agent. The free
# text is the valuable part: "the bar disappears after unplugging the monitor"
# is a symptom no collector reports, and it is the difference between an agent
# reading a health summary and an agent looking for something.
cmd_diagnose() {
  local launcher="$PANAMA_DIR/bin/panama-agent"
  if [[ ! -x "$launcher" ]]; then
    err "The agent launcher is missing from $launcher"
    exit 1
  fi

  local complaint="$*"

  local health="(the health check did not run)"
  local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor"
  if [[ -x "$doctor" ]]; then
    health="$("$doctor" --summary 2>&1)" || true
  fi

  # Bounded twice, and not out of tidiness. journalctl counts entries, not
  # lines, and thirty entries on this machine came to 2,430 lines and a quarter
  # of a megabyte -- one multi-line traceback each. The prompt leaves as a
  # single argv element, which the kernel caps at 128KB, so an unbounded excerpt
  # turns this command into "Argument list too long" rather than a diagnosis.
  local errors="(nothing at error level in this boot's user journal)"
  if command -v journalctl >/dev/null 2>&1; then
    local recent
    recent="$(journalctl --user -b -p err -n 30 --no-pager --output=short 2>/dev/null \
      | cut -c 1-300 | tail -80)" || true
    [[ -n "${recent// }" ]] && errors="$recent"
  fi

  local complaint_section="Nothing in particular was reported; this was run to look around."
  [[ -n "${complaint// }" ]] && complaint_section="$complaint"

  local prompt
  prompt="$(cat <<PROMPT
Something is wrong with this Panama machine and I would like to know what.

What I noticed:

$complaint_section

What panama doctor --summary says:

$health

The last error-level lines in this boot's user journal:

$errors

Panama is checked out at $PANAMA_DIR and every dotfile in ~/.config is a symlink
into it, so anything you find is a tracked file here rather than a copy. Start
by reading: work out what is actually broken and say so before changing
anything. If a check is red, 'panama doctor' with no arguments has the long form
of it. Root work goes through panama-sudo, which shows me your reason.
PROMPT
)"

  # exec: from here on the agent's terminal is the process, and this shell has
  # nothing left to do that the agent is not doing better.
  exec "$launcher" --prompt "$prompt"
}

# ----------------------------------------------------------------------------
# Contract manifest
# ----------------------------------------------------------------------------
#
# The manifest is the runtime authority for every collected contract. An absent
# manifest is unsafe: this command must never infer that unclassified tests are
# hermetic.
CONTRACT_MANIFEST="tests/contracts.manifest"
CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network privileged)

contract_paths() {
  local candidate
  # The manifest is kept in byte order, so both the discovery sort and the
  # comparison below have to be byte order too. A UTF-8 collation folds the
  # punctuation away -- `calendar_agenda_bridge_test.py` sorts before
  # `calendar-agenda-helper-contract` under en_US and after it under C -- and
  # a gate that passes or fails on the machine's LANG is not a gate.
  while IFS= read -r candidate; do
    [[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
    printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
  done < <(find "$PANAMA_DIR/tests" -type f \
    -not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
}

contract_manifest_entries() {
  local line capabilities path
  while IFS= read -r line || [[ -n "$line" ]]; do
    [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue
    IFS=$' \t' read -r capabilities path <<<"$line"
    printf '%s\t%s\n' "$path" "$capabilities"
  done < "$PANAMA_DIR/$CONTRACT_MANIFEST"
}

require_contract_manifest() {
  [[ -r "$PANAMA_DIR/$CONTRACT_MANIFEST" ]] || {
    err "Contract manifest is missing or unreadable: $PANAMA_DIR/$CONTRACT_MANIFEST"
    return 1
  }
}

validate_contract_manifest() {
  require_contract_manifest || return 1

  local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST"
  # Byte order, for the same reason contract_paths sorts in it.
  local LC_ALL=C
  local line capabilities path extra previous_comment="" previous_was_comment=0
  local previous_path="" capability discovered
  local -a capability_list=() findings=()
  local -A expected_contracts=() manifest_paths=()

  while IFS= read -r discovered; do
    expected_contracts["$discovered"]=1
  done < <(contract_paths)

  while IFS= read -r line || [[ -n "$line" ]]; do
    if [[ "$line" =~ ^[[:space:]]*# ]]; then
      previous_comment="${line#*#}"
      previous_comment="${previous_comment#"${previous_comment%%[![:space:]]*}"}"
      previous_comment="${previous_comment%"${previous_comment##*[![:space:]]}"}"
      previous_was_comment=1
      continue
    fi

    if [[ "$line" =~ ^[[:space:]]*$ ]]; then
      previous_comment=""
      previous_was_comment=0
      continue
    fi

    IFS=$' \t' read -r capabilities path extra <<<"$line"
    if [[ -z "${capabilities:-}" || -z "${path:-}" || -n "${extra:-}" ]]; then
      findings+=("manifest line is not exactly two fields: $line")
      previous_comment=""
      previous_was_comment=0
      continue
    fi

    if [[ -n "$previous_path" && "$path" < "$previous_path" ]]; then
      findings+=('paths are not lexicographically sorted')
    fi
    previous_path="$path"

    if [[ -n "${manifest_paths[$path]:-}" ]]; then
      findings+=("duplicate path $path")
    fi
    manifest_paths["$path"]=1

    local -A line_capabilities=()
    if [[ "$capabilities" == ,* || "$capabilities" == *, || "$capabilities" == *,,* ]]; then
      findings+=("empty capability on $path")
    fi
    IFS=',' read -r -a capability_list <<<"$capabilities"
    for capability in "${capability_list[@]}"; do
      [[ -n "$capability" ]] || continue
      if [[ -n "${line_capabilities[$capability]:-}" ]]; then
        findings+=("duplicate capability $capability on $path")
      fi
      line_capabilities["$capability"]=1
      is_contract_capability "$capability" \
        || findings+=("unknown capability $capability on $path")
    done

    if [[ -n "${line_capabilities[hermetic]:-}" && ${#line_capabilities[@]} -ne 1 ]]; then
      findings+=("hermetic must appear alone on $path")
    fi

    if [[ "$capabilities" != hermetic ]]; then
      if (( previous_was_comment != 1 )); then
        findings+=("$path is non-hermetic but lacks a directly preceding comment")
      elif [[ -z "$previous_comment" ]]; then
        findings+=("$path is non-hermetic but lacks a non-empty directly preceding comment")
      fi
    fi
    previous_comment=""
    previous_was_comment=0
  done < "$manifest"

  for discovered in "${!expected_contracts[@]}"; do
    [[ -n "${manifest_paths[$discovered]:-}" ]] \
      || findings+=("missing contract $discovered")
  done
  for path in "${!manifest_paths[@]}"; do
    [[ -n "${expected_contracts[$path]:-}" ]] \
      || findings+=("stale manifest path $path")
  done

  if (( ${#findings[@]} > 0 )); then
    err "Contract manifest validation failed with ${#findings[@]} finding(s):"
    printf '  - %s\n' "${findings[@]}" >&2
    return 1
  fi
}

test_usage() {
  err "Usage: ${BOLD}$PROGRAM test [--safe] [--allow <capability>] [pattern]${RESET}"
  return 2
}

is_contract_capability() {
  local capability="$1" known
  for known in "${CONTRACT_CAPABILITIES[@]}"; do
    [[ "$capability" == "$known" ]] && return 0
  done
  return 1
}

# ----------------------------------------------------------------------------
# Command: test
# ----------------------------------------------------------------------------
#
# The contracts are the main safety net in this repository. The manifest is the
# single list of what the runner executes and which external boundaries each
# contract reaches.
#
# Each runs in its own process and a failure does not stop the rest, because the
# useful output is the whole list of what is broken rather than the first thing
# that broke. The exit code is what a caller can act on.
#
# --safe runs only contracts the manifest classifies as hermetic and reports
# each external capability it skipped. A plain terminal run asks before any
# selected non-hermetic work. Automation must grant every required capability
# with repeatable --allow flags. Non-hermetic contracts announce their exact
# capability list before execution. Each contract gets an outer timeout, 180
# seconds by default. PANAMA_TEST_TIMEOUT_SECONDS accepts a positive integer
# override. Failures include captured stdout and stderr. Successful stdout
# stays quiet, while successful stderr is surfaced as a warning.
PANAMA_ACTIVE_CONTRACT_PID=""
PANAMA_CONTRACT_CAPTURE_DIR=""

cleanup_contract_capture() {
  if [[ -n "$PANAMA_CONTRACT_CAPTURE_DIR" && -d "$PANAMA_CONTRACT_CAPTURE_DIR" ]]; then
    rm -rf -- "$PANAMA_CONTRACT_CAPTURE_DIR" || true
  fi
  PANAMA_CONTRACT_CAPTURE_DIR=""
}

terminate_active_contract() {
  local pid="$PANAMA_ACTIVE_CONTRACT_PID"
  PANAMA_ACTIVE_CONTRACT_PID=""
  [[ "$pid" =~ ^[1-9][0-9]*$ && "$pid" != "$$" ]] || return 0

  # GNU timeout owns a process group whose ID is its PID. Signal that complete
  # group so a contract cannot leave descendants behind, with a direct-PID
  # fallback for implementations that do not create the group.
  kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
  wait "$pid" 2>/dev/null || true
}

handle_contract_signal() {
  local signal_status="$1"
  trap - INT TERM
  terminate_active_contract
  cleanup_contract_capture
  trap - EXIT
  exit "$signal_status"
}

prepare_contract_capture() {
  local capture_dir=""
  if ! capture_dir="$(mktemp -d)"; then
    err 'Could not create contract capture directory.'
    return 1
  fi
  if [[ -z "$capture_dir" || ! -d "$capture_dir" ]]; then
    err 'Could not create contract capture directory.'
    return 1
  fi

  PANAMA_CONTRACT_CAPTURE_DIR="$capture_dir"
  trap cleanup_contract_capture EXIT
  trap 'handle_contract_signal 130' INT
  trap 'handle_contract_signal 143' TERM
}

cmd_test() {
  local timeout_seconds="${PANAMA_TEST_TIMEOUT_SECONDS:-180}"
  [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || {
    err 'PANAMA_TEST_TIMEOUT_SECONDS must be a positive integer.'
    return 2
  }

  validate_contract_manifest || return 1
  cmd_test_impl "$timeout_seconds" "$@"
}

cmd_test_impl() {
  local timeout_seconds="$1"
  shift
  local pattern="" safe=0 arg capability capabilities rel path
  local -A grants=() manifest_capabilities=() skipped_counts=() missing_grants=() selected_capabilities=()
  local -a suite=() missing_capability_list=() selected_capability_list=() capability_list=()

  # Position-independent: flags can precede or follow the optional pattern.
  while (( $# > 0 )); do
    arg="$1"
    shift
    case "$arg" in
      --safe) safe=1 ;;
      --allow)
        (( $# > 0 )) || { test_usage; return 2; }
        capability="$1"
        shift
        is_contract_capability "$capability" || {
          err "Unknown contract capability: $capability"
          return 2
        }
        [[ "$capability" != hermetic ]] || {
          err 'hermetic contracts do not need --allow.'
          return 2
        }
        grants["$capability"]=1
        ;;
      --*) test_usage; return 2 ;;
      *)
        [[ -z "$pattern" ]] || { test_usage; return 2; }
        pattern="$arg"
        ;;
    esac
  done

  (( safe == 0 || ${#grants[@]} == 0 )) || {
    err '--safe cannot be combined with --allow.'
    return 2
  }

  while IFS=$'\t' read -r rel capabilities; do
    manifest_capabilities["$rel"]="$capabilities"
    [[ -z "$pattern" || "$rel" == *"$pattern"* ]] || continue
    if (( safe )) && [[ "$capabilities" != hermetic ]]; then
      IFS=',' read -r -a capability_list <<<"$capabilities"
      for capability in "${capability_list[@]}"; do
        (( ++skipped_counts["$capability"] ))
      done
      continue
    fi
    suite+=("$rel")
  done < <(contract_manifest_entries)

  if (( ${#suite[@]} == 0 )); then
    if (( safe )) && (( ${#skipped_counts[@]} > 0 )); then
      err "Every contract matching '${pattern}' needs an external capability; --safe skipped all of them."
    else
      err "No contracts match '${pattern}'"
    fi
    return 1
  fi

  if (( safe )); then
    for capability in "${CONTRACT_CAPABILITIES[@]}"; do
      [[ "$capability" == hermetic ]] && continue
      printf 'Skipped %d %s contract(s).\n' "${skipped_counts[$capability]:-0}" "$capability"
    done
  else
    for rel in "${suite[@]}"; do
      capabilities="${manifest_capabilities[$rel]}"
      [[ "$capabilities" == hermetic ]] && continue
      IFS=',' read -r -a capability_list <<<"$capabilities"
      for capability in "${capability_list[@]}"; do
        selected_capabilities["$capability"]=1
        [[ -n "${grants[$capability]:-}" ]] || missing_grants["$capability"]=1
      done
    done
    for capability in "${CONTRACT_CAPABILITIES[@]}"; do
      [[ "$capability" == hermetic ]] && continue
      [[ -n "${selected_capabilities[$capability]:-}" ]] && selected_capability_list+=("$capability")
      [[ -n "${missing_grants[$capability]:-}" ]] && missing_capability_list+=("$capability")
    done
    if (( ${#missing_capability_list[@]} > 0 )); then
      if [[ -t 0 && -t 2 ]]; then
        confirm "Run ${#suite[@]} contract(s) requiring: ${selected_capability_list[*]}?" || {
          warn 'No contracts were run.'
          return 1
        }
      else
        err "Selected contracts require: ${missing_capability_list[*]}."
        for capability in "${missing_capability_list[@]}"; do
          printf '  Automation: pass --allow %s\n' "$capability" >&2
        done
        return 1
      fi
    fi
  fi

  local capture_dir stdout_file stderr_file name run_status index=0 final_status=0
  local -a failed=() runner=()
  prepare_contract_capture || return 1
  capture_dir="$PANAMA_CONTRACT_CAPTURE_DIR"

  info "Running ${#suite[@]} contract(s)"
  for index in "${!suite[@]}"; do
    rel="${suite[$index]}"
    path="$PANAMA_DIR/$rel"
    name="${rel#tests/}"
    stdout_file="$capture_dir/$index.stdout"
    stderr_file="$capture_dir/$index.stderr"
    if [[ "$path" == *_test.py ]]; then
      runner=(python3 "$path")
    else
      runner=("$path")
    fi
    capabilities="${manifest_capabilities[$rel]}"
    if [[ "$capabilities" != hermetic ]]; then
      info "Running $name [$capabilities]"
    fi
    run_status=0
    timeout --signal=TERM --kill-after=5 "$timeout_seconds" \
      "${runner[@]}" >"$stdout_file" 2>"$stderr_file" &
    PANAMA_ACTIVE_CONTRACT_PID=$!
    wait "$PANAMA_ACTIVE_CONTRACT_PID" || run_status=$?
    PANAMA_ACTIVE_CONTRACT_PID=""
    if (( run_status == 0 )); then
      ok "$name"
      if [[ -s "$stderr_file" ]]; then
        warn "$name wrote to stderr:"
        cat "$stderr_file" >&2
      fi
      continue
    fi

    if (( run_status == 124 || run_status == 137 )); then
      err "$name timed out after ${timeout_seconds}s"
    else
      err "$name failed (exit $run_status)"
    fi
    [[ -s "$stdout_file" ]] && {
      printf '%s stdout:\n' "$name" >&2
      cat "$stdout_file" >&2
    }
    [[ -s "$stderr_file" ]] && {
      printf '%s stderr:\n' "$name" >&2
      cat "$stderr_file" >&2
    }
    failed+=("$name")
  done

  header "Result"
  if (( ${#failed[@]} == 0 )); then
    ok "${#suite[@]} contract(s) passed"
  else
    err "${#failed[@]} of ${#suite[@]} failed:"
    printf '    %s\n' "${failed[@]}" >&2
    final_status=1
  fi

  cleanup_contract_capture
  trap - EXIT INT TERM
  return "$final_status"
}

# ----------------------------------------------------------------------------
# Command: contracts
# ----------------------------------------------------------------------------
#
# "I changed this file -- what should I run?" The suite is large enough that
# running all of it or guessing from contract names are both poor answers.
#
# This is a grep, and says so. A contract that names the file, or a
# parent-trimmed suffix of it, or just its basename, is a contract worth
# running; one that reaches the file through a harness or a generated artifact
# is not found, which is why the empty answer says "coverage may be indirect"
# rather than "nothing covers this". Naming a file the suite does not mention is
# a real answer -- exit 1 so a script can tell the difference -- but it is a
# statement about this search, not about the file.
#
# Each hit is labeled from tests/contracts.manifest, so the output also answers
# which boundary the matching contract reaches.
cmd_contracts() {
  local target="${1:-}"
  if [[ -z "$target" ]]; then
    err "Which file? Usage: ${BOLD}$PROGRAM contracts <file>${RESET}"
    exit 1
  fi

  # Absolute, relative to where you are standing, or repo-relative -- all three
  # are how somebody refers to a file in this tree, and readlink resolves the
  # symlinked dotfile in ~/.config back into the checkout it points at.
  local absolute=""
  if [[ -e "$target" ]]; then
    absolute="$(readlink -f "$target")"
  elif [[ -e "$PANAMA_DIR/$target" ]]; then
    absolute="$(readlink -f "$PANAMA_DIR/$target")"
  else
    err "No such file: '$target'"
    printf 'Give a path, absolute or relative to here or to %s.\n' "$PANAMA_DIR" >&2
    exit 1
  fi

  local path
  case "$absolute" in
    "$PANAMA_DIR"/*) path="${absolute#"$PANAMA_DIR"/}" ;;
    *)
      err "'$target' is outside the Panama repo (${PANAMA_DIR})."
      exit 1
      ;;
  esac

  # The repo-relative path, then each parent trimmed off in turn, ending at the
  # basename. Contracts refer to their subject every one of these ways: by the
  # full path from the repo root, by the path from the shell directory, and by
  # name alone.
  local -a patterns=()
  local suffix="$path"
  while :; do
    patterns+=(-e "$suffix")
    [[ "$suffix" == */* ]] || break
    suffix="${suffix#*/}"
  done

  validate_contract_manifest || return 1
  local -A manifest_capabilities=()
  local capabilities
  while IFS=$'\t' read -r rel capabilities; do
    manifest_capabilities["$rel"]="$capabilities"
  done < <(contract_manifest_entries)

  # The same collection `test` runs, so anything named here is something the
  # runner would actually execute.
  local -a hits=()
  local candidate rel
  while IFS= read -r rel; do
    candidate="$PANAMA_DIR/$rel"
    grep -qF "${patterns[@]}" "$candidate" 2>/dev/null || continue
    hits+=("$rel")
  done < <(contract_paths)

  if (( ${#hits[@]} == 0 )); then
    printf 'No contract mentions %s — coverage may be indirect (a harness or a generated artifact); nothing verified.\n' "$path" >&2
    exit 1
  fi

  for rel in "${hits[@]}"; do
    [[ -n "${manifest_capabilities[$rel]:-}" ]] || {
      err "Contract has no manifest capability label: $rel"
      return 1
    }
    printf '%s  [%s]\n' "$rel" "${manifest_capabilities[$rel]}"
  done
}

# ----------------------------------------------------------------------------
# Command: upgrade
# ----------------------------------------------------------------------------
#
# ./install is the upgrade path -- every stage is idempotent and re-running is
# the documented way to repair a machine. This only saves remembering where the
# repository lives.
cmd_upgrade() {
  local installer="$PANAMA_DIR/install"
  if [[ ! -x "$installer" ]]; then
    err "The installer is missing from $installer"
    exit 1
  fi
  info "Re-running ${BOLD}${installer}${RESET}"
  cd "$PANAMA_DIR"
  exec "$installer" "$@"
}

# ----------------------------------------------------------------------------
# Command: migrate
# ----------------------------------------------------------------------------
#
# What ./install cannot do. The installer only ever adds -- it copies over /,
# links dotfiles, installs packages -- so a machine set up months ago keeps
# whatever this repository has since decided was wrong. Migrations are the one
# mechanism that can remove a file, disable a unit, or repair a symlink on a
# machine that already exists. See bin/panama-migrate.
cmd_migrate() {
  local runner="$PANAMA_DIR/bin/panama-migrate"
  if [[ ! -x "$runner" ]]; then
    err "The migration runner is missing from $runner"
    exit 1
  fi
  exec "$runner" "$@"
}

# ----------------------------------------------------------------------------
# Command: app
# ----------------------------------------------------------------------------
#
# The applications that neither dnf nor Flathub carries, built from source into
# a package dnf can still own and remove.
#
# Deliberately NOT part of ./install. A source build is slow, wants the network
# for the whole of it, and depends on an upstream that moves -- which is exactly
# the failure the interview exists to prevent: twenty minutes in, a prompt or an
# error, with nobody at the keyboard. Asking for one of these is a thing you do
# on purpose, and it is also the rebuild path when a new version ships.
#
# Nothing is pinned. Each build takes the current default branch and the current
# upstream release, and says so when it fails. A recorded version is a 404
# waiting to happen -- sunhat proved that three times over.
APPS_DIR="$PANAMA_DIR/setup/apps"
APPS_WORK="${XDG_CACHE_HOME:-$HOME/.cache}/panama/apps"

cmd_app() {
  local name="${1:-}"

  if [[ -z "$name" ]]; then
    header "Applications"
    printf 'Built from source, because no repository carries them.\n\n'
    local file
    for file in "$APPS_DIR"/*; do
      [[ -f "$file" ]] || continue
      local description=""
      # shellcheck source=/dev/null
      source "$file"
      printf '  %s%-18s%s %s\n' "$GREEN" "$(basename "$file")" "$RESET" "$description"
    done
    printf '\nBuild one with: %s%s app <name>%s\n' "$BOLD" "$PROGRAM" "$RESET"
    return 0
  fi

  local definition="$APPS_DIR/$name"
  if [[ ! -f "$definition" ]]; then
    err "No such application: '$name'"
    printf "Run '%s app' to see what is available.\n" "$PROGRAM" >&2
    exit 1
  fi

  local description="" repo=""
  # shellcheck source=/dev/null
  source "$definition"
  [[ -n "$repo" ]] || { err "$name declares no repository"; exit 1; }

  # The checkout lives in the cache because it is entirely rebuildable and
  # should never be mistaken for something to keep. Existing checkouts are
  # reset to upstream rather than merged: a local edit in a build tree is not
  # something to preserve silently.
  local tree="$APPS_WORK/$name"
  if [[ -d "$tree/.git" ]]; then
    info "Updating $name"
    git -C "$tree" fetch --depth 1 origin HEAD || { err "Could not reach $repo"; exit 1; }
    git -C "$tree" reset --hard FETCH_HEAD >/dev/null
  else
    info "Cloning $name"
    mkdir -p "$APPS_WORK"
    rm -rf "$tree"
    git clone --depth 1 "$repo" "$tree" || { err "Could not clone $repo"; exit 1; }
  fi

  info "Building ${BOLD}${name}${RESET} — this takes a while and needs the network"
  if ( cd "$tree" && build ); then
    ok "$name installed"
    printf 'Built from %s\n' "$(git -C "$tree" rev-parse --short HEAD)"
  else
    err "$name failed to build"
    printf 'The tree is left at %s so the failure can be read.\n' "$tree" >&2
    printf 'This builds against upstream HEAD, so a break there breaks this.\n' >&2
    exit 1
  fi
}

# ----------------------------------------------------------------------------
# Command: apps
# ----------------------------------------------------------------------------
# The optional applications, chosen a few at a time rather than all at once.
#
# ./install offers the same catalog as whole categories, because during a first
# install you want coarse and fast. This is the other end of it: pick a
# category, then tick the applications inside, and install just those. Both read
# setup/lib/extras-catalog, so neither can drift from the other.
#
# Source-built applications appear here too, as their own category. They are not
# part of ./install for good reason -- a build is slow, wants the network
# throughout, and can fail on an upstream that moved -- but "choose, then
# install" is exactly the right shape for them, which is what this is.
cmd_apps() {
  if ! command -v gum >/dev/null 2>&1; then
    err "gum is not installed. It is in setup/packages/initial-packages; run 'panama upgrade'."
    exit 1
  fi

  # shellcheck source=../setup/lib/extras-catalog
  source "$PANAMA_DIR/setup/lib/extras-catalog"

  local extras_dir="$PANAMA_DIR/setup/packages/extras"
  local apps_dir="$PANAMA_DIR/setup/apps"

  local -a categories=()
  mapfile -t categories < <(catalog_categories "$extras_dir")
  [[ -d "$apps_dir" ]] && categories+=("built from source")

  (( ${#categories[@]} > 0 )) || { err "No application categories found."; exit 1; }

  local category
  category="$(gum choose --header "Which kind of application?" "${categories[@]}")" || return 0
  [[ -n "$category" ]] || return 0

  if [[ "$category" == "built from source" ]]; then
    cmd_apps_source "$apps_dir"
    return
  fi

  local file="$extras_dir/$category"
  local -a labels=() targets=()
  local target label marker
  while IFS=$'\t' read -r target label; do
    [[ -n "$target" ]] || continue
    # Marked, not hidden: reinstalling what is present is harmless, but a menu
    # that silently omits it leaves you wondering where it went.
    if catalog_installed "$target"; then marker=" (installed)"; else marker=""; fi
    targets+=("$target")
    labels+=("$label$marker")
  done < <(catalog_entries "$file")

  local chosen
  chosen="$(gum choose --no-limit --header "$category — space to select, enter to accept" "${labels[@]}")" || return 0
  [[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }

  # Back from labels to install targets, then out to everything each one carries.
  local -a install_targets=()
  local pick i
  while IFS= read -r pick; do
    [[ -n "$pick" ]] || continue
    for i in "${!labels[@]}"; do
      if [[ "${labels[$i]}" == "$pick" ]]; then
        mapfile -t -O "${#install_targets[@]}" install_targets < <(catalog_targets "$file" "${targets[$i]}")
        break
      fi
    done
  done <<< "$chosen"

  local -a dnf_packages=() flatpak_ids=()
  for target in "${install_targets[@]}"; do
    if [[ "$target" == flatpak:* ]]; then flatpak_ids+=("${target#flatpak:}"); else dnf_packages+=("$target"); fi
  done

  header "About to install"
  (( ${#dnf_packages[@]} > 0 )) && printf '  dnf      %s\n' "${dnf_packages[*]}"
  (( ${#flatpak_ids[@]} > 0 )) && printf '  flatpak  %s\n' "${flatpak_ids[*]}"
  echo
  confirm "Install these?" || { warn "Nothing installed."; return 0; }

  if (( ${#dnf_packages[@]} > 0 )); then
    info "Installing ${#dnf_packages[@]} package(s) with dnf"
    sudo dnf install -y "${dnf_packages[@]}" || warn "Some packages did not install"
  fi
  if (( ${#flatpak_ids[@]} > 0 )); then
    info "Installing ${#flatpak_ids[@]} flatpak(s)"
    sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo >/dev/null
    sudo flatpak install -y flathub "${flatpak_ids[@]}" || warn "Some flatpaks did not install"
  fi
  ok "Done."
}

# The source-built applications, offered by the same two-step flow and handed to
# the existing `panama app` so there is one build path, not two.
cmd_apps_source() {
  local apps_dir="$1"
  local -a names=()
  mapfile -t names < <(for f in "$apps_dir"/*; do [[ -f "$f" ]] && basename "$f"; done)
  (( ${#names[@]} > 0 )) || { err "No applications in $apps_dir."; return 1; }

  local chosen
  chosen="$(gum choose --no-limit --header "Built from source — these take a while" "${names[@]}")" || return 0
  [[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }

  local name
  while IFS= read -r name; do
    [[ -n "$name" ]] || continue
    header "Building $name"
    cmd_app "$name" || warn "$name did not build"
  done <<< "$chosen"
}

# ----------------------------------------------------------------------------
# Dispatcher
# ----------------------------------------------------------------------------
main() {
  local cmd="${1:-}"
  case "$cmd" in
    update)        shift; cmd_update "$@" ;;
    sync)          shift; cmd_sync "$@" ;;
    edit)          shift; cmd_edit "$@" ;;
    doctor)        shift; cmd_doctor "$@" ;;
    diagnose)      shift; cmd_diagnose "$@" ;;
    test)          shift; cmd_test "$@" ;;
    contracts)     shift; cmd_contracts "$@" ;;
    upgrade)       shift; cmd_upgrade "$@" ;;
    migrate)       shift; cmd_migrate "$@" ;;
    app)           shift; cmd_app "$@" ;;
    apps)          shift; cmd_apps "$@" ;;
    server)        shift; exec "$PANAMA_DIR/bin/panama-server" "$@" ;;
    help|-h|--help|"") usage ;;
    --version)     printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
    *)
      err "Unknown command: '$cmd'"
      echo
      usage
      exit 1
      ;;
  esac
}

main "$@"
