#!/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 every contract under tests/ (--safe skips the hijacking ones)
#   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"
  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 every contract under tests/. Give it a pattern to run
              a subset: 'panama test dock' runs the ones matching 'dock'.
              --safe skips the ones that take over the live desktop; what
              they are and why is tests/desktop-hijacking.
  ${GREEN}contracts${RESET}   Name the contracts that mention a given file, each marked
              safe or desktop. 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 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"
}

# ----------------------------------------------------------------------------
# The desktop-hijacking ledger
# ----------------------------------------------------------------------------
#
# tests/desktop-hijacking lists, one repo-relative path per line with a '#'
# comment saying what it does to the live session, the contracts that drive the
# real shell, compositor or machine rather than a harness. Read by `test --safe`
# to decide what to skip, and by `contracts` to mark each hit.
#
# Prints the paths, comments and blank lines stripped. A missing ledger prints
# nothing: no ledger means nothing is known to hijack, which is the honest
# reading of an absent file and keeps `--safe` from failing on a fresh checkout.
DESKTOP_HIJACKING_LEDGER="tests/desktop-hijacking"

hijacking_entries() {
  local ledger="$PANAMA_DIR/$DESKTOP_HIJACKING_LEDGER" line
  [[ -r "$ledger" ]] || return 0
  while IFS= read -r line || [[ -n "$line" ]]; do
    line="${line%%#*}"
    line="${line#"${line%%[![:space:]]*}"}"
    line="${line%"${line##*[![:space:]]}"}"
    [[ -n "$line" ]] && printf '%s\n' "$line"
  done < "$ledger"
  return 0
}

# ----------------------------------------------------------------------------
# Command: test
# ----------------------------------------------------------------------------
#
# The contracts are the main safety net in this repository and had no entry
# point: 121 executables with no runner and no mention in the README, which is
# most of the way to not having them.
#
# 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 exists because a fair number of these contracts ARE the desktop: they
# open overlays, restart the shell, move your windows. Running the suite while
# sitting in front of the machine used to mean losing the session for a few
# minutes, so the honest options were "run everything" or "run nothing". --safe
# is the third: skip exactly what tests/desktop-hijacking names, and say how
# many were skipped, so the gap is stated rather than implied.
cmd_test() {
  local pattern="" safe=0 arg
  # Position-independent, because 'panama test --safe dock' and
  # 'panama test dock --safe' are the same intent and nobody should have to
  # remember which one this accepts.
  for arg in "$@"; do
    case "$arg" in
      --safe) safe=1 ;;
      *)      pattern="$arg" ;;
    esac
  done

  local -a suite=()
  local -A hijacking=()
  local skipped=0 entry rel

  if (( safe )); then
    while IFS= read -r entry; do
      hijacking["$entry"]=1
    done < <(hijacking_entries)
  fi

  # Executables, plus the Python suites. Those are unittest files rather than
  # executables, and collecting only what has the executable bit would skip them
  # without saying so -- which is how all three came to be run by nothing at all.
  # A runner with a blind spot is worse than no runner, because it reports PASS.
  while IFS= read -r path; do
    [[ -x "$path" || "$path" == *_test.py ]] || continue
    [[ -z "$pattern" || "$path" == *"$pattern"* ]] || continue
    rel="tests/${path#"$PANAMA_DIR"/tests/}"
    if (( safe )) && [[ -n "${hijacking[$rel]:-}" ]]; then
      (( ++skipped ))
      continue
    fi
    suite+=("$path")
  done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)

  if (( ${#suite[@]} == 0 )); then
    # "Nothing matched" and "everything that matched was skipped" are different
    # answers, and reporting the first for the second is how --safe would come
    # to look like a broken pattern.
    if (( skipped > 0 )); then
      err "Every contract matching '${pattern}' is desktop-hijacking; --safe skipped all ${skipped}."
      printf '  What they do to the session: %s/%s\n' "$PANAMA_DIR" "$DESKTOP_HIJACKING_LEDGER" >&2
    else
      err "No contracts match '${pattern}'"
    fi
    exit 1
  fi

  info "Running ${#suite[@]} contract(s)"
  local -a failed=()
  local path name
  local -a runner
  for path in "${suite[@]}"; do
    name="${path#"$PANAMA_DIR"/tests/}"
    if [[ "$path" == *_test.py ]]; then
      runner=(python3 "$path")
    else
      runner=("$path")
    fi
    if "${runner[@]}" >/dev/null 2>&1; then
      ok "$name"
    else
      err "$name"
      failed+=("$name")
    fi
  done

  header "Result"
  if (( ${#failed[@]} == 0 )); then
    ok "${#suite[@]} contract(s) passed"
    if (( safe )); then
      printf 'Skipped %d desktop-hijacking contract(s) (%s).\n' "$skipped" "$DESKTOP_HIJACKING_LEDGER"
    fi
    return 0
  fi
  err "${#failed[@]} of ${#suite[@]} failed:"
  printf '    %s\n' "${failed[@]}" >&2
  warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}"
  if (( safe )); then
    printf 'Skipped %d desktop-hijacking contract(s) (%s).\n' "$skipped" "$DESKTOP_HIJACKING_LEDGER"
  fi
  return 1
}

# ----------------------------------------------------------------------------
# Command: contracts
# ----------------------------------------------------------------------------
#
# "I changed this file -- what should I run?" There are 177 contracts and no
# index, so the honest answers were "all of them" (minutes, and half of them
# take the desktop away) or "the ones whose name sounds related" (which is how
# a covering contract gets skipped).
#
# 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 marked from tests/desktop-hijacking, so the output also answers
# "and can I run them right now".
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

  local -A hijacking=()
  local entry
  while IFS= read -r entry; do
    hijacking["$entry"]=1
  done < <(hijacking_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 candidate; do
    [[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
    grep -qF "${patterns[@]}" "$candidate" 2>/dev/null || continue
    hits+=("tests/${candidate#"$PANAMA_DIR"/tests/}")
  done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)

  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
    if [[ -n "${hijacking[$rel]:-}" ]]; then
      printf '%s  [desktop]\n' "$rel"
    else
      printf '%s  [safe]\n' "$rel"
    fi
  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 "$@"
