#!/usr/bin/env bash
#
# panama – Helper for managing the Panama dotfiles/repo
# Version 1.0
# Author: Gabriel Brown
#
# Commands:
#   update   Commit & sync local changes (or just pull if clean)
#   edit     Open the Panama repo in Neovim
#   doctor   Report what is actually running on this machine
#   test     Run every contract under tests/
#   upgrade  Re-run the installer from anywhere
#   apps     Choose applications to install, by category
#   app      Build and install an application that no repository packages
#   help     Show this help
#
# 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}      Review, commit & sync local changes. If the working tree is
              clean it simply runs 'git pull'.
  ${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}test${RESET}        Run every contract under tests/. Give it a pattern to run
              a subset: 'panama test dock' runs the ones matching 'dock'.
  ${GREEN}upgrade${RESET}     Re-run ./install from anywhere. Safe: every stage is
              idempotent and this is the documented upgrade path.
  ${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}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 edit
  $PROGRAM doctor --summary
  $PROGRAM test dock
  $PROGRAM upgrade
  $PROGRAM apps
  $PROGRAM app
  $PROGRAM app claude-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
# ----------------------------------------------------------------------------
cmd_update() {
  require_git_repo
  cd "$PANAMA_DIR"

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

  # Any changes in the working tree? (modified, staged, or untracked)
  if [[ -z "$(git status --porcelain)" ]]; then
    info "Working tree is clean — pulling latest changes."
    if git pull --ff-only; then
      ok "Already in sync."
    else
      err "git pull failed."
      exit 1
    fi
    return
  fi

  # Show what changed
  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

  # Commit message
  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

  # Is the local branch up to date with its upstream?
  info "Checking whether the repo is up to date..."
  if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
    git fetch --quiet
    local local_rev remote_rev base_rev
    local_rev=$(git rev-parse @)
    remote_rev=$(git rev-parse '@{u}')
    base_rev=$(git merge-base @ '@{u}')

    if [[ "$local_rev" == "$remote_rev" ]]; then
      ok "Repo is up to date."
    elif [[ "$local_rev" == "$base_rev" ]]; then
      warn "Repo is behind upstream — stashing, pulling, then re-applying."
      info "Stashing local changes..."
      git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null

      if ! git pull --ff-only; then
        err "git pull failed — restoring your changes."
        git stash pop || true
        exit 1
      fi

      info "Re-applying stashed changes..."
      if ! git stash pop; then
        err "Conflict while re-applying changes. Resolve it, then commit manually."
        exit 1
      fi
    else
      warn "Local branch has diverged from upstream — committing locally only."
    fi
  else
    warn "No upstream configured for this branch — committing locally only."
  fi

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

  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: 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.
cmd_test() {
  local pattern="${1:-}"
  local -a suite=()

  # 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"* ]] && suite+=("$path")
  done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)

  if (( ${#suite[@]} == 0 )); then
    err "No contracts match '${pattern}'"
    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"
    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}"
  return 1
}

# ----------------------------------------------------------------------------
# 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: 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 "$@" ;;
    edit)          shift; cmd_edit "$@" ;;
    doctor)        shift; cmd_doctor "$@" ;;
    test)          shift; cmd_test "$@" ;;
    upgrade)       shift; cmd_upgrade "$@" ;;
    app)           shift; cmd_app "$@" ;;
    apps)          shift; cmd_apps "$@" ;;
    help|-h|--help|"") usage ;;
    --version)     printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
    *)
      err "Unknown command: '$cmd'"
      echo
      usage
      exit 1
      ;;
  esac
}

main "$@"
