#!/usr/bin/env bash
# stacks — Docker Compose stack manager
set -euo pipefail

# ─── Bootstrap ────────────────────────────────────────────────────────────────

SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
# STACKS_DIR: explicit env var > current working directory
STACKS_DIR="${STACKS_DIR:-$PWD}"
ROOT_ENV="$STACKS_DIR/.env"
VERSION="1.0.0"

# ─── Colors ───────────────────────────────────────────────────────────────────

if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
  BOLD=$'\e[1m' DIM=$'\e[2m'
  RED=$'\e[31m' GREEN=$'\e[32m' YELLOW=$'\e[33m'
  BLUE=$'\e[34m' MAGENTA=$'\e[35m' CYAN=$'\e[36m'
  RESET=$'\e[0m'
else
  BOLD='' DIM='' RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' RESET=''
fi

# ─── Output ───────────────────────────────────────────────────────────────────

log()     { [[ $QUIET == true ]] && return; echo -e "${BOLD}[stacks]${RESET} $*"; }
info()    { [[ $QUIET == true ]] && return; echo -e "${CYAN}  →${RESET} $*"; }
ok()      { [[ $QUIET == true ]] && return; echo -e "${GREEN}  ✓${RESET} $*"; }
warn()    { echo -e "${YELLOW}  ⚠${RESET} $*" >&2; }
err()     { echo -e "${RED}  ✗${RESET} $*" >&2; }
die()     { err "$*"; exit 1; }
header()  { [[ $QUIET == true ]] && return; echo -e "\n${BOLD}${BLUE}━━━ $* ━━━${RESET}"; }
sep()     { [[ $QUIET == true ]] && return; echo -e "${DIM}────────────────────────────────────────${RESET}"; }

# ─── Root env + Notifications ────────────────────────────────────────────────

load_root_env() {
  if [[ -f "$ROOT_ENV" ]]; then
    # shellcheck disable=SC1090
    set -a; source "$ROOT_ENV"; set +a
  else
    warn "No root .env found at $ROOT_ENV — create it from .env.example"
  fi
}

_notify() {
  local color="$1" text="$2"
  local webhook="${WEBHOOK_URL:-}"
  if [[ -z "$webhook" ]]; then
    warn "WEBHOOK_URL not set — skipping notification"
    return 0
  fi
  curl -sf -o /dev/null -X POST "$webhook" \
    -H 'Content-Type: application/json' \
    -d "{\"message\":\"$text\",\"color\":\"$color\"}" \
    || warn "Webhook notification failed"
}

# ─── Global Flags ─────────────────────────────────────────────────────────────

DRY_RUN=false
PARALLEL=false
VERBOSE=false
QUIET=false

# ─── Stack Discovery ──────────────────────────────────────────────────────────

list_all_stacks() {
  local stacks=()
  for d in "$STACKS_DIR"/*/; do
    [[ ! -d "$d" ]] && continue
    local name
    name="$(basename "$d")"
    [[ "$name" == _* ]] && continue
    [[ ! -f "$d/compose.yml" ]] && continue
    stacks+=("$name")
  done
  [[ ${#stacks[@]} -gt 0 ]] && printf '%s\n' "${stacks[@]}"
}

# Resolve FILTER args to a de-duplicated list of stack names.
# Supports exact names, glob patterns (*git*, g*), and comma-separated lists.
# Prints one stack name per line; dies if nothing matches.
match_stacks() {
  local -a patterns=("$@")
  local -a all_stacks=()
  mapfile -t all_stacks < <(list_all_stacks)

  if [[ ${#patterns[@]} -eq 0 ]]; then
    printf '%s\n' "${all_stacks[@]}"
    return
  fi

  local -A seen=()
  local -a matched=()

  for pattern in "${patterns[@]}"; do
    IFS=',' read -ra parts <<< "$pattern"
    for part in "${parts[@]}"; do
      part="${part// /}"
      [[ -z "$part" ]] && continue
      local found=false
      for stack in "${all_stacks[@]}"; do
        # shellcheck disable=SC2254  # intentional glob in [[ ]]
        if [[ "$stack" == $part ]]; then
          if [[ -z "${seen[$stack]+x}" ]]; then
            seen[$stack]=1
            matched+=("$stack")
          fi
          found=true
        fi
      done
      $found || warn "No stack matches pattern: ${BOLD}$part${RESET}"
    done
  done

  [[ ${#matched[@]} -eq 0 ]] && die "No matching stacks found"
  printf '%s\n' "${matched[@]}"
}

# ─── Compose Runner ───────────────────────────────────────────────────────────

run_compose() {
  local stack="$1"; shift
  local stack_dir="$STACKS_DIR/$stack"

  [[ ! -d "$stack_dir" ]]       && { err "Stack directory not found: $stack"; return 1; }
  [[ ! -f "$stack_dir/compose.yml" ]] && { err "No compose.yml in: $stack"; return 1; }

  local -a env_flag=()
  [[ -f "$stack_dir/.env" ]] && env_flag=(--env-file .env)

  if $VERBOSE; then
    info "${BOLD}$stack${RESET}: docker compose ${env_flag[*]} $*"
  fi

  if $DRY_RUN; then
    echo -e "  ${DIM}[dry-run]${RESET} ${CYAN}$stack${RESET}: docker compose ${env_flag[*]} $*"
    return 0
  fi

  (cd "$stack_dir" && docker compose "${env_flag[@]}" "$@")
}

# Run a compose command across multiple stacks, optionally in parallel.
# Usage: run_for_stacks stacks_array_ref COMPOSE_CMD...
run_for_stacks() {
  local -n _stacks="$1"; shift
  local -a compose_cmd=("$@")
  local -i errors=0

  if $PARALLEL && [[ ${#_stacks[@]} -gt 1 ]]; then
    log "Running in parallel: ${BOLD}${compose_cmd[*]}${RESET} on ${#_stacks[@]} stacks"
    local -a pids=()
    local -a names=()
    for stack in "${_stacks[@]}"; do
      run_compose "$stack" "${compose_cmd[@]}" &
      pids+=($!)
      names+=("$stack")
    done
    for i in "${!pids[@]}"; do
      if ! wait "${pids[$i]}"; then
        err "Failed: ${names[$i]}"
        ((errors++)) || true
      fi
    done
  else
    for stack in "${_stacks[@]}"; do
      sep
      if ! run_compose "$stack" "${compose_cmd[@]}"; then
        ((errors++)) || true
      fi
    done
    sep
  fi

  return $errors
}

# ─── Stack Commands ───────────────────────────────────────────────────────────

cmd_ls() {
  local -a all_stacks=()
  mapfile -t all_stacks < <(list_all_stacks)

  if [[ ${#all_stacks[@]} -eq 0 ]]; then
    warn "No stacks found in $STACKS_DIR"
    return
  fi

  printf "\n${BOLD}%-18s %-10s %-12s %s${RESET}\n" "STACK" "STATUS" "CONTAINERS" "ENV"
  sep

  for stack in "${all_stacks[@]}"; do
    local stack_dir="$STACKS_DIR/$stack"
    local env_label has_env
    if [[ -f "$stack_dir/.env" ]]; then
      env_label="${GREEN}✓ .env${RESET}"
      has_env=true
    else
      env_label="${YELLOW}✗ .env${RESET}"
      has_env=false
    fi

    local running="0" total="0"
    { running=$(docker ps    --filter "label=com.docker.compose.project=$stack" -q 2>/dev/null | wc -l); } 2>/dev/null || running="?"
    { total=$(  docker ps -a --filter "label=com.docker.compose.project=$stack" -q 2>/dev/null | wc -l); } 2>/dev/null || total="?"
    running="${running// /}"
    total="${total// /}"

    local status status_color
    if [[ "$running" == "?" || "$total" == "?" ]]; then
      status="${DIM}unknown${RESET}"
      status_color="$DIM"
    elif [[ "$total" -eq 0 ]]; then
      status="${DIM}stopped${RESET}"
      status_color="$DIM"
    elif [[ "$running" -eq "$total" ]]; then
      status="${GREEN}running${RESET}"
      status_color="$GREEN"
    elif [[ "$running" -eq 0 ]]; then
      status="${RED}down${RESET}"
      status_color="$RED"
    else
      status="${YELLOW}partial${RESET}"
      status_color="$YELLOW"
    fi

    printf "  ${BOLD}%-16s${RESET}  %-10b  ${status_color}%s/%s${RESET}%-7s  %b\n" \
      "$stack" "$status" "$running" "$total" "" "$env_label"
  done
  echo
}

cmd_ps() {
  local -a filters=("$@")
  local -a stacks=()
  mapfile -t stacks < <(match_stacks "${filters[@]}")
  for stack in "${stacks[@]}"; do
    header "$stack"
    run_compose "$stack" ps --format table || true
  done
}

cmd_up() {
  local -a filters=("$@")
  local -a stacks=()
  mapfile -t stacks < <(match_stacks "${filters[@]}")
  log "Starting ${#stacks[@]} stack(s): ${stacks[*]}"
  run_for_stacks stacks up -d
}

cmd_down() {
  local remove_volumes=false
  local -a filters=()
  for arg in "$@"; do
    [[ "$arg" == "--volumes" || "$arg" == "-v" ]] && { remove_volumes=true; continue; }
    filters+=("$arg")
  done

  local -a stacks=()
  mapfile -t stacks < <(match_stacks "${filters[@]}")
  log "Stopping ${#stacks[@]} stack(s): ${stacks[*]}"
  if $remove_volumes; then
    warn "Removing volumes too (--volumes)"
    run_for_stacks stacks down --volumes
  else
    run_for_stacks stacks down
  fi
}

cmd_restart() {
  local -a filters=("$@")
  local -a stacks=()
  mapfile -t stacks < <(match_stacks "${filters[@]}")
  log "Restarting ${#stacks[@]} stack(s): ${stacks[*]}"
  run_for_stacks stacks restart
}

cmd_pull() {
  local -a filters=("$@")
  local -a stacks=()
  mapfile -t stacks < <(match_stacks "${filters[@]}")
  log "Pulling images for ${#stacks[@]} stack(s): ${stacks[*]}"
  run_for_stacks stacks pull
}

cmd_logs() {
  local -a dc_flags=()
  local -a filters=()

  while [[ $# -gt 0 ]]; do
    case "$1" in
      -f|--follow|-t|--timestamps|--no-color) dc_flags+=("$1"); shift ;;
      -n|--tail)   dc_flags+=("$1" "${2:?--tail requires a value}"); shift 2 ;;
      --tail=*)    dc_flags+=("$1"); shift ;;
      --since|--until) dc_flags+=("$1" "${2:?$1 requires a value}"); shift 2 ;;
      -*) dc_flags+=("$1"); shift ;;
      *)  filters+=("$1"); shift ;;
    esac
  done

  local -a stacks=()
  mapfile -t stacks < <(match_stacks "${filters[@]}")

  for stack in "${stacks[@]}"; do
    [[ ${#stacks[@]} -gt 1 ]] && header "$stack"
    run_compose "$stack" logs "${dc_flags[@]}" || true
  done
}

cmd_exec() {
  [[ $# -lt 3 ]] && die "Usage: $SCRIPT_NAME exec STACK SERVICE CMD [ARGS...]"
  local stack="$1" service="$2"; shift 2
  run_compose "$stack" exec "$service" "$@"
}

cmd_run_svc() {
  [[ $# -lt 3 ]] && die "Usage: $SCRIPT_NAME run STACK SERVICE CMD [ARGS...]"
  local stack="$1" service="$2"; shift 2
  run_compose "$stack" run --rm "$service" "$@"
}

cmd_config() {
  local -a filters=("$@")
  local -a stacks=()
  mapfile -t stacks < <(match_stacks "${filters[@]}")
  for stack in "${stacks[@]}"; do
    header "$stack"
    run_compose "$stack" config
  done
}

cmd_images() {
  local -a filters=("$@")
  local -a stacks=()
  mapfile -t stacks < <(match_stacks "${filters[@]}")
  for stack in "${stacks[@]}"; do
    header "$stack"
    run_compose "$stack" images
  done
}

cmd_top() {
  local -a filters=("$@")
  local -a stacks=()
  mapfile -t stacks < <(match_stacks "${filters[@]}")
  for stack in "${stacks[@]}"; do
    header "$stack"
    run_compose "$stack" top || true
  done
}

cmd_stats() {
  local -a filters=("$@")
  local -a stacks=()
  mapfile -t stacks < <(match_stacks "${filters[@]}")
  local -a containers=()
  for stack in "${stacks[@]}"; do
    local -a running=()
    mapfile -t running < <(
      docker ps --filter "label=com.docker.compose.project=$stack" --format '{{.Names}}' 2>/dev/null
    )
    containers+=("${running[@]+"${running[@]}"}")
  done
  if [[ ${#containers[@]} -eq 0 ]]; then
    warn "No running containers for the selected stacks"
    return
  fi
  docker stats "${containers[@]}"
}

# Passthrough: run any docker compose subcommand directly on a stack
cmd_compose() {
  [[ $# -lt 1 ]] && die "Usage: $SCRIPT_NAME compose STACK [CMD...]"
  local stack="$1"; shift
  run_compose "$stack" "$@"
}

# ─── Update Service ───────────────────────────────────────────────────────────

# Resolve each service's image tag to its current local SHA256 ID.
# Reads from the local image store (updated by 'docker compose pull'),
# NOT from running containers — so the comparison reflects the pulled image.
_stack_image_ids() {
  local stack_dir="$1"; shift
  local -a env_flag=("$@")
  local images
  # 'config --images' (compose v2.19+) outputs one image name per line;
  # fall back to parsing the resolved config yaml for older versions.
  if images=$(cd "$stack_dir" && docker compose "${env_flag[@]}" config --images 2>/dev/null) \
      && [[ -n "$images" ]]; then
    echo "$images"
  else
    cd "$stack_dir" && docker compose "${env_flag[@]}" config 2>/dev/null \
      | awk '/^\s+image:/{print $2}' | sort -u
  fi | xargs -r docker image inspect --format '{{.Id}}' 2>/dev/null | sort
}

_update_run() {
  load_root_env

  local -a all_stacks=()
  mapfile -t all_stacks < <(list_all_stacks)
  local -a updated=() failed=()
  local -i checked=0

  log "Starting image update check (${#all_stacks[@]} stacks)"

  for stack in "${all_stacks[@]}"; do
    local stack_dir="$STACKS_DIR/$stack"
    local -a env_flag=()
    [[ -f "$stack_dir/.env" ]] && env_flag=(--env-file .env)

    info "Checking ${BOLD}$stack${RESET}"
    ((checked++)) || true

    local before after
    before=$(_stack_image_ids "$stack_dir" "${env_flag[@]+"${env_flag[@]}"}") || true
    (cd "$stack_dir" && docker compose "${env_flag[@]}" pull 2>&1) || true
    after=$(_stack_image_ids "$stack_dir" "${env_flag[@]+"${env_flag[@]}"}") || true

    if [[ "$before" != "$after" ]]; then
      info "  Updates found — recreating $stack"
      if (cd "$stack_dir" && docker compose "${env_flag[@]}" up -d 2>&1); then
        updated+=("$stack")
        ok "  $stack updated"
      else
        err "  Failed to recreate $stack"
        failed+=("$stack")
      fi
    else
      info "  Up to date"
    fi
  done

  log "Pruning dangling images"
  docker image prune -f

  local date_str
  date_str="$(date '+%Y-%m-%d %H:%M')"

  if [[ ${#failed[@]} -gt 0 ]]; then
    _notify "#cc0000" "❌ **Update failed** — $date_str\nUpdated: ${updated[*]:-none}\nFailed: ${failed[*]}"
    return 1
  elif [[ ${#updated[@]} -gt 0 ]]; then
    _notify "#36a64f" "✅ **Updates applied** — $date_str\nStacks updated (${#updated[@]}/$checked): ${updated[*]}"
  else
    _notify "#36a64f" "✅ **All up to date** — $date_str ($checked stacks checked)"
  fi

  log "Update check complete"
}

cmd_update() {
  local sub="${1:-help}"; shift || true

  case "$sub" in
    install)
      log "Installing update service"
      if $DRY_RUN; then
        info "[dry-run] Would write /etc/systemd/system/stacks-update.{service,timer}"
        info "[dry-run] systemctl enable --now stacks-update.timer"
        return
      fi
      local _self; _self="$(realpath "${BASH_SOURCE[0]}")"
      cat > /etc/systemd/system/stacks-update.service << EOF
[Unit]
Description=Docker stacks image update
After=network-online.target docker.service
Wants=network-online.target

[Service]
Type=oneshot
User=root
ExecStart=${_self} update run
Environment="HOME=/root" "STACKS_DIR=${STACKS_DIR}"
EOF
      cat > /etc/systemd/system/stacks-update.timer << 'EOF'
[Unit]
Description=Daily Docker stacks image update at 2:00 AM

[Timer]
OnCalendar=*-*-* 02:00:00 Europe/Amsterdam
Persistent=true

[Install]
WantedBy=timers.target
EOF
      systemctl daemon-reload
      systemctl enable --now stacks-update.timer
      ok "Update service installed and enabled"
      systemctl status stacks-update.timer --no-pager || true
      ;;

    uninstall)
      log "Uninstalling update service"
      if $DRY_RUN; then info "[dry-run] Would uninstall update service"; return; fi
      systemctl disable --now stacks-update.timer 2>/dev/null || true
      rm -f /etc/systemd/system/stacks-update.{service,timer}
      systemctl daemon-reload
      ok "Update service removed"
      ;;

    run)
      log "Running update now"
      if $DRY_RUN; then info "[dry-run] Would run image update across all stacks"; return; fi
      _update_run
      ;;

    status)
      if systemctl cat stacks-update.timer &>/dev/null; then
        SYSTEMD_COLORS=1 systemctl status stacks-update.timer stacks-update.service --no-pager 2>/dev/null || true
      else
        warn "Update service not installed"
      fi
      ;;

    logs)
      journalctl -u stacks-update.service "${@}" --no-pager 2>/dev/null \
        || warn "No update logs found (is the service installed?)"
      ;;

    next)
      systemctl list-timers stacks-update.timer --no-pager 2>/dev/null \
        || warn "Timer not active"
      ;;

    help|"")
      echo "Usage: $SCRIPT_NAME update <subcommand>"
      echo "Subcommands: install, uninstall, run, status, logs, next"
      ;;

    *)
      die "Unknown update subcommand: $sub  (install|uninstall|run|status|logs|next)"
      ;;
  esac
}

# ─── Backup Service ───────────────────────────────────────────────────────────

_backup_run() {
  load_root_env

  [[ -n "${RESTIC_REPOSITORY:-}" ]] || die "RESTIC_REPOSITORY not set in $ROOT_ENV"
  [[ -n "${RESTIC_PASSWORD:-}"   ]] || die "RESTIC_PASSWORD not set in $ROOT_ENV"
  export RESTIC_REPOSITORY RESTIC_PASSWORD

  local data_dir="$STACKS_DIR/.data"
  local dump_dir="$data_dir/backup/dumps"

  log "Starting backup"
  mkdir -p "$dump_dir"

  # --- Postgres dumps: detect <stack>_db containers by convention ---
  local -a all_stacks=()
  mapfile -t all_stacks < <(list_all_stacks)

  local -a dump_errors=()
  local -i dumped=0

  for stack in "${all_stacks[@]}"; do
    local container="${stack}_db"
    # Check the container exists and is running
    if docker ps --filter "name=^/${container}$" --filter "status=running" -q 2>/dev/null | grep -q .; then
      info "Dumping ${BOLD}$stack${RESET} DB ($container, user: $stack, db: $stack)"
      if docker exec "$container" pg_dump -U "$stack" "$stack" \
          > "$dump_dir/${stack}.sql" 2>/dev/null; then
        local size
        size="$(du -h "$dump_dir/${stack}.sql" 2>/dev/null | cut -f1)"
        ok "  $stack DB dumped ($size)"
        ((dumped++)) || true
      else
        err "  Dump failed: $container"
        dump_errors+=("$stack")
        rm -f "$dump_dir/${stack}.sql"
      fi
    fi
  done

  [[ $dumped -gt 0 || ${#dump_errors[@]} -gt 0 ]] \
    && info "DB dumps: $dumped ok, ${#dump_errors[@]} failed"

  # --- Restic backup ---
  log "Running restic backup of $data_dir"
  if ! restic backup "$data_dir" --tag stacks 2>&1; then
    err "FATAL: restic backup failed"
    _notify "#cc0000" "❌ **Backup failed** — restic error. Check \`journalctl -u stacks-backup\`."
    return 1
  fi

  # --- Prune ---
  log "Pruning old snapshots"
  restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6 2>&1 \
    || warn "restic forget failed"

  # --- Summary ---
  local snapshot_info repo_stats summary
  snapshot_info="$(restic snapshots --latest 1 --compact 2>/dev/null | tail -3 | head -1 || true)"
  repo_stats="$(restic stats 2>/dev/null | grep "Total Size" || true)"

  summary="✅ **Backup complete** — $(date '+%Y-%m-%d %H:%M')"
  [[ $dumped -gt 0 ]]            && summary+="\nDB dumps: $dumped stacks"
  [[ ${#dump_errors[@]} -gt 0 ]] && summary+="\n⚠️ Failed dumps: ${dump_errors[*]}"
  [[ -n "$snapshot_info" ]]      && summary+="\nLatest: \`$snapshot_info\`"
  [[ -n "$repo_stats" ]]         && summary+="\nRepo: $repo_stats"

  _notify "#36a64f" "$summary"
  log "Backup complete"
}

cmd_backup() {
  local sub="${1:-help}"; shift || true

  case "$sub" in
    install)
      log "Installing backup service"
      if $DRY_RUN; then
        info "[dry-run] Would write /etc/systemd/system/stacks-backup.{service,timer}"
        info "[dry-run] systemctl enable --now stacks-backup.timer"
        return
      fi
      local _self; _self="$(realpath "${BASH_SOURCE[0]}")"
      cat > /etc/systemd/system/stacks-backup.service << EOF
[Unit]
Description=Backup stacks data to HiDrive via restic
After=network-online.target docker.service
Wants=network-online.target

[Service]
Type=oneshot
User=root
ExecStart=${_self} backup run
Environment="HOME=/root" "STACKS_DIR=${STACKS_DIR}"
EOF
      cat > /etc/systemd/system/stacks-backup.timer << 'EOF'
[Unit]
Description=Daily stacks backup at 3:00 AM

[Timer]
OnCalendar=*-*-* 03:00:00 Europe/Amsterdam
Persistent=true

[Install]
WantedBy=timers.target
EOF
      systemctl daemon-reload
      systemctl enable --now stacks-backup.timer
      ok "Backup service installed and enabled"
      systemctl status stacks-backup.timer --no-pager || true
      ;;

    uninstall)
      log "Uninstalling backup service"
      if $DRY_RUN; then info "[dry-run] Would uninstall backup service"; return; fi
      systemctl disable --now stacks-backup.timer 2>/dev/null || true
      rm -f /etc/systemd/system/stacks-backup.{service,timer}
      systemctl daemon-reload
      ok "Backup service removed"
      ;;

    run)
      log "Running backup now"
      if $DRY_RUN; then info "[dry-run] Would run restic backup with dynamic DB detection"; return; fi
      _backup_run
      ;;

    status)
      if systemctl cat stacks-backup.timer &>/dev/null; then
        SYSTEMD_COLORS=1 systemctl status stacks-backup.timer stacks-backup.service --no-pager 2>/dev/null || true
      else
        warn "Backup service not installed"
      fi
      ;;

    logs)
      journalctl -u stacks-backup.service "${@}" --no-pager 2>/dev/null \
        || warn "No backup logs found (is the service installed?)"
      ;;

    next)
      systemctl list-timers stacks-backup.timer --no-pager 2>/dev/null \
        || warn "Timer not active"
      ;;

    snapshots)
      load_root_env
      [[ -n "${RESTIC_REPOSITORY:-}" ]] || die "RESTIC_REPOSITORY not set in $ROOT_ENV"
      [[ -n "${RESTIC_PASSWORD:-}"   ]] || die "RESTIC_PASSWORD not set in $ROOT_ENV"
      export RESTIC_REPOSITORY RESTIC_PASSWORD
      restic snapshots 2>/dev/null || die "restic not installed or repo not initialised"
      ;;

    help|"")
      echo "Usage: $SCRIPT_NAME backup <subcommand>"
      echo "Subcommands: install, uninstall, run, status, logs, next, snapshots"
      ;;

    *)
      die "Unknown backup subcommand: $sub  (install|uninstall|run|status|logs|next|snapshots)"
      ;;
  esac
}

# ─── New Stack Scaffold ───────────────────────────────────────────────────────

cmd_new() {
  local name=""
  local with_db=""
  local with_redis=false
  local no_traefik=false

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --db)       with_db="${2:?--db requires a value (postgres|mysql|mariadb)}"; shift 2 ;;
      --db=*)     with_db="${1#--db=}"; shift ;;
      --redis)    with_redis=true; shift ;;
      --no-traefik) no_traefik=true; shift ;;
      -*)         die "Unknown flag: $1" ;;
      *)          [[ -z "$name" ]] && name="$1" || die "Unexpected argument: $1"; shift ;;
    esac
  done

  [[ -z "$name" ]] && die "Usage: $SCRIPT_NAME new NAME [--db postgres|mysql|mariadb] [--redis] [--no-traefik]"
  [[ "$name" == _* ]] && die "Stack name cannot start with underscore (_)"
  [[ "$name" =~ ^[a-z0-9_-]+$ ]] || die "Stack name must be lowercase alphanumeric (a-z, 0-9, -, _)"

  local stack_dir="$STACKS_DIR/$name"
  [[ -d "$stack_dir" ]] && die "Stack already exists: $name"

  log "Scaffolding stack: ${BOLD}$name${RESET}"
  [[ -n "$with_db" ]] && info "  + database: $with_db"
  $with_redis && info "  + redis"
  $no_traefik || info "  + traefik labels"

  if $DRY_RUN; then
    info "[dry-run] Would create:"
    info "  $stack_dir/compose.yml"
    info "  $stack_dir/.env.example"
    return
  fi

  mkdir -p "$stack_dir"

  # --- .env.example ---
  {
    echo "# Network"
    echo "NETWORK_NAME=falcon_network"
    $no_traefik || echo "TRAEFIK_HOST=${name}.example.com"
    echo ""
    echo "# Timezone"
    echo "TIMEZONE=Europe/Amsterdam"
    echo ""
    echo "# Application"
    echo "# APP_SECRET=$(openssl rand -hex 32 2>/dev/null || echo 'changeme')"

    if [[ -n "$with_db" ]]; then
      echo ""
      echo "# Database"
      echo "DB_NAME=$name"
      echo "DB_USER=$name"
      echo "DB_PASSWORD=changeme"
    fi
  } > "$stack_dir/.env.example"

  # --- compose.yml ---
  {
    echo "services:"
    echo "  app:"
    echo "    image: # TODO: set image"
    echo "    container_name: ${name}"
    echo "    environment:"
    echo "      TZ: \${TIMEZONE:-Europe/Amsterdam}"
    if [[ -n "$with_db" ]]; then
      echo "      # DB_HOST: ${name}_db"
      echo "      # DB_NAME: \${DB_NAME}"
      echo "      # DB_USER: \${DB_USER}"
      echo "      # DB_PASSWORD: \${DB_PASSWORD}"
    fi
    if $with_redis; then
      echo "      # REDIS_URL: redis://${name}_redis:6379"
    fi
    echo "    volumes:"
    echo "      - ../.data/${name}/data:/data"
    echo "    restart: always"
    if [[ -n "$with_db" ]]; then
      echo "    depends_on:"
      echo "      db:"
      echo "        condition: service_healthy"
    fi
    echo "    healthcheck:"
    echo "      test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:PORT/health\"]"
    echo "      interval: 30s"
    echo "      timeout: 5s"
    echo "      retries: 3"
    echo "      start_period: 15s"

    if $no_traefik; then
      echo "    ports:"
      echo "      - \"8080:8080\"  # TODO: adjust port"
    else
      cat << LABELS
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.${name}-web.rule=Host(\`\${TRAEFIK_HOST}\`)"
      - "traefik.http.routers.${name}-web.entrypoints=web"
      - "traefik.http.routers.${name}-web.middlewares=${name}-redirect-web-secure"
      - "traefik.http.middlewares.${name}-redirect-web-secure.redirectscheme.scheme=https"
      - "traefik.http.routers.${name}-web-secure.rule=Host(\`\${TRAEFIK_HOST}\`)"
      - "traefik.http.routers.${name}-web-secure.entrypoints=web-secure"
      - "traefik.http.routers.${name}-web-secure.tls.certresolver=resolver"
      - "traefik.http.routers.${name}-web-secure.middlewares=security-headers@file"
      - "traefik.http.services.${name}-web-secure.loadbalancer.server.port=8080"
      - "traefik.docker.network=\${NETWORK_NAME}"
LABELS
    fi

    echo "    networks:"
    echo "      - compose_network"

    # --- DB service ---
    if [[ -n "$with_db" ]]; then
      echo ""
      case "$with_db" in
        postgres|postgresql)
          cat << POSTGRES
  db:
    image: postgres:16-alpine
    container_name: ${name}_db
    environment:
      POSTGRES_DB: \${DB_NAME:-$name}
      POSTGRES_USER: \${DB_USER:-$name}
      POSTGRES_PASSWORD: \${DB_PASSWORD:-changeme}
    volumes:
      - ../.data/${name}/db:/var/lib/postgresql/data
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U \$\${POSTGRES_USER} -d \$\${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - compose_network
POSTGRES
          ;;
        mysql)
          cat << MYSQL
  db:
    image: mysql:8
    container_name: ${name}_db
    environment:
      MYSQL_DATABASE: \${DB_NAME:-$name}
      MYSQL_USER: \${DB_USER:-$name}
      MYSQL_PASSWORD: \${DB_PASSWORD:-changeme}
      MYSQL_ROOT_PASSWORD: \${DB_ROOT_PASSWORD:-rootchangeme}
    volumes:
      - ../.data/${name}/db:/var/lib/mysql
    restart: always
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - compose_network
MYSQL
          ;;
        mariadb)
          cat << MARIADB
  db:
    image: mariadb:11
    container_name: ${name}_db
    environment:
      MARIADB_DATABASE: \${DB_NAME:-$name}
      MARIADB_USER: \${DB_USER:-$name}
      MARIADB_PASSWORD: \${DB_PASSWORD:-changeme}
      MARIADB_ROOT_PASSWORD: \${DB_ROOT_PASSWORD:-rootchangeme}
    volumes:
      - ../.data/${name}/db:/var/lib/mysql
    restart: always
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - compose_network
MARIADB
          ;;
        *)
          die "Unknown DB type: $with_db  (postgres|mysql|mariadb)"
          ;;
      esac
    fi

    # --- Redis service ---
    if $with_redis; then
      echo ""
      cat << REDIS
  redis:
    image: redis:7-alpine
    container_name: ${name}_redis
    restart: always
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - compose_network
REDIS
    fi

    echo ""
    echo "networks:"
    echo "  compose_network:"
    echo "    name: \${NETWORK_NAME}"
    echo "    external: true"
  } > "$stack_dir/compose.yml"

  ok "Scaffolded: ${BOLD}$name${RESET}"
  info "  Edit:  ${stack_dir}/compose.yml"
  info "  Env:   cp ${stack_dir}/.env.example ${stack_dir}/.env && \$EDITOR ${stack_dir}/.env"
  info "  Start: $SCRIPT_NAME up $name"
}

# ─── Shell Completion ─────────────────────────────────────────────────────────

_completion_bash() {
  local stacks_init="$1"  # pre-rendered stacks array initialisation block
  cat << 'EOF'
# stacks.sh bash completion — source this file or place in /etc/bash_completion.d/
_stacks_complete() {
  local cur prev words cword
  _init_completion 2>/dev/null || {
    COMPREPLY=()
    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"
  }

  local script="${COMP_WORDS[0]}"
EOF
  echo "$stacks_init"
  cat << 'EOF'

  local commands="ls ps up down restart pull logs exec run config images top stats compose update backup new completion version help"
  local update_subs="install uninstall run status logs next"
  local backup_subs="install uninstall run status logs next snapshots"
  local global_flags="--dry-run --parallel --verbose --quiet --help --version"

  case "${COMP_WORDS[1]}" in
    update)  COMPREPLY=( $(compgen -W "$update_subs" -- "$cur") ); return ;;
    backup)  COMPREPLY=( $(compgen -W "$backup_subs" -- "$cur") ); return ;;
    completion) COMPREPLY=( $(compgen -W "bash zsh --install --static" -- "$cur") ); return ;;
    new)
      case "$prev" in
        --db)   COMPREPLY=( $(compgen -W "postgres mysql mariadb" -- "$cur") ); return ;;
        new)    COMPREPLY=(); return ;;
        *)      COMPREPLY=( $(compgen -W "--db --redis --no-traefik" -- "$cur") ); return ;;
      esac
      ;;
    exec|run)
      if [[ $COMP_CWORD -eq 2 ]]; then
        COMPREPLY=( $(compgen -W "${stacks[*]}" -- "$cur") )
      fi
      return ;;
    compose)
      if [[ $COMP_CWORD -eq 2 ]]; then
        COMPREPLY=( $(compgen -W "${stacks[*]}" -- "$cur") )
      fi
      return ;;
    down)
      COMPREPLY=( $(compgen -W "${stacks[*]} --volumes" -- "$cur") ); return ;;
    logs)
      case "$prev" in
        -n|--tail|--since|--until) COMPREPLY=(); return ;;
      esac
      COMPREPLY=( $(compgen -W "${stacks[*]} -f --follow -n --tail --since --until -t --timestamps" -- "$cur") )
      return ;;
    up|restart|pull|ps|config|images|top|stats)
      COMPREPLY=( $(compgen -W "${stacks[*]}" -- "$cur") ); return ;;
  esac

  if [[ "$cur" == -* ]]; then
    COMPREPLY=( $(compgen -W "$global_flags" -- "$cur") ); return
  fi

  if [[ $COMP_CWORD -eq 1 ]]; then
    COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
  fi
}

complete -F _stacks_complete stacks.sh
complete -F _stacks_complete stacks
EOF
}

_completion_zsh() {
  local stacks_init="$1"  # pre-rendered stacks array initialisation block
  cat << 'EOF'
#compdef stacks.sh stacks

_stacks() {
  local script="${words[1]}"
EOF
  echo "$stacks_init"
  cat << 'EOF'

  local -a commands=(
    'ls:List all stacks with live status'
    'ps:Show container status'
    'up:Start stack(s)'
    'down:Stop stack(s)'
    'restart:Restart stack(s)'
    'pull:Pull latest images'
    'logs:Show logs'
    'exec:Execute command in a running container'
    'run:Run a one-off command in a new container'
    'config:Show resolved compose config'
    'images:Show images used by stack(s)'
    'top:Display running processes'
    'stats:Live container resource stats'
    'compose:Pass-through to docker compose'
    'update:Manage the update systemd service'
    'backup:Manage the backup systemd service'
    'new:Scaffold a new stack'
    'completion:Generate shell completion script'
    'version:Show version'
    'help:Show help'
  )

  local -a update_subs=(
    'install:Install and enable update timer'
    'uninstall:Remove update timer'
    'run:Run update now'
    'status:Show update service status'
    'logs:Show update service logs'
    'next:Show next scheduled run'
  )

  local -a backup_subs=(
    'install:Install and enable backup timer'
    'uninstall:Remove backup timer'
    'run:Run backup now'
    'status:Show backup service status'
    'logs:Show backup service logs'
    'next:Show next scheduled run'
    'snapshots:List restic snapshots'
  )

  _arguments -C \
    '(-n --dry-run)'{-n,--dry-run}'[Show what would be done without executing]' \
    '(-p --parallel)'{-p,--parallel}'[Run operations in parallel]' \
    '(-v --verbose)'{-v,--verbose}'[Verbose output]' \
    '(-q --quiet)'{-q,--quiet}'[Suppress non-error output]' \
    '(-h --help)'{-h,--help}'[Show help]' \
    '--version[Show version]' \
    '1:command:->cmd' \
    '*:args:->args' && return

  case "$state" in
    cmd)
      _describe 'command' commands
      ;;
    args)
      case "${words[2]}" in
        up|restart|pull|ps|config|images|top|stats)
          _values 'stack' "${stacks[@]}"
          ;;
        down)
          _arguments \
            '--volumes[Also remove volumes]' \
            '*:stack:'"(${stacks[*]})"
          ;;
        logs)
          _arguments \
            '(-f --follow)'{-f,--follow}'[Follow log output]' \
            '(-n --tail)'{-n,--tail}'[Number of lines to show]:lines' \
            '--since[Show logs since timestamp]:timestamp' \
            '--until[Show logs until timestamp]:timestamp' \
            '(-t --timestamps)'{-t,--timestamps}'[Show timestamps]' \
            '*:stack:'"(${stacks[*]})"
          ;;
        exec|run|compose)
          if [[ $CURRENT -eq 3 ]]; then
            _values 'stack' "${stacks[@]}"
          fi
          ;;
        update)
          _describe 'subcommand' update_subs
          ;;
        backup)
          _describe 'subcommand' backup_subs
          ;;
        new)
          _arguments \
            '--db[Add a database service]:engine:(postgres mysql mariadb)' \
            '--redis[Add a Redis service]' \
            '--no-traefik[Skip Traefik labels, expose port instead]' \
            '1:name:_message "stack name"'
          ;;
        completion)
          _arguments \
            '--install[Install the completion file]' \
            '--static[Bake current stack list into completion script]' \
            '1:shell:(bash zsh)'
          ;;
      esac
      ;;
  esac
}

_stacks "$@"
EOF
}

cmd_completion() {
  local shell="" install=false static=false

  for arg in "$@"; do
    case "$arg" in
      bash|zsh)    shell="$arg" ;;
      --install)   install=true ;;
      --static)    static=true ;;
      *)           die "Unknown argument: $arg  (bash|zsh [--install] [--static])" ;;
    esac
  done

  if [[ -z "$shell" ]]; then
    shell="$(basename "${SHELL:-bash}")"
    [[ "$shell" == "bash" || "$shell" == "zsh" ]] || shell="bash"
    warn "No shell specified, defaulting to: $shell"
  fi

  # Build the stacks array initialisation block — either dynamic (runtime discovery)
  # or static (current list baked in at generation time).
  local bash_stacks_init zsh_stacks_init

  if $static; then
    local -a cur_stacks=()
    mapfile -t cur_stacks < <(list_all_stacks)
    [[ ${#cur_stacks[@]} -eq 0 ]] && die "No stacks found to bake into completion"
    local generated_at
    generated_at="$(date '+%Y-%m-%d')"
    bash_stacks_init="  # Stacks baked in at generation time (${generated_at}) — rerun to update
  local -a stacks=(${cur_stacks[*]})"
    zsh_stacks_init="  # Stacks baked in at generation time (${generated_at}) — rerun to update
  local -a stacks=(${cur_stacks[*]})"
    info "Baking ${#cur_stacks[@]} stacks: ${cur_stacks[*]}"
  else
    bash_stacks_init='  # Dynamically discover stacks from the script location at completion time
  local stacks_dir
  stacks_dir="$(cd "$(dirname "$(realpath "$script" 2>/dev/null || echo "$script")")" 2>/dev/null && pwd)"

  local -a stacks=()
  if [[ -d "$stacks_dir" ]]; then
    for d in "$stacks_dir"/*/; do
      local n; n="$(basename "$d")"
      [[ "$n" == _* ]] && continue
      [[ -f "$d/compose.yml" ]] && stacks+=("$n")
    done
  fi'
    zsh_stacks_init='  # Dynamically discover stacks from the script location at completion time
  local stacks_dir
  stacks_dir="$(cd "$(dirname "$(realpath "$script" 2>/dev/null || echo "$script")")" 2>/dev/null && pwd)"

  local -a stacks=()
  if [[ -d "$stacks_dir" ]]; then
    for d in "$stacks_dir"/*/; do
      local n="${d:t}"
      [[ "$n" == _* ]] && continue
      [[ -f "${d}compose.yml" ]] && stacks+=("$n")
    done
  fi'
  fi

  local content
  case "$shell" in
    bash) content="$(_completion_bash "$bash_stacks_init")" ;;
    zsh)  content="$(_completion_zsh  "$zsh_stacks_init")" ;;
    *)    die "Unsupported shell: $shell  (bash|zsh)" ;;
  esac

  if ! $install; then
    echo "$content"
    return
  fi

  local dest
  case "$shell" in
    bash)
      if [[ -w /etc/bash_completion.d ]]; then
        dest="/etc/bash_completion.d/stacks"
      else
        dest="${HOME}/.local/share/bash-completion/completions/stacks"
        mkdir -p "$(dirname "$dest")"
      fi
      ;;
    zsh)
      dest=""
      # prefer first writable fpath dir
      local -a fpath_dirs=()
      while IFS=: read -ra fpath_dirs; do :; done <<< "${FPATH:-}"
      for d in "${fpath_dirs[@]}"; do
        [[ -d "$d" && -w "$d" ]] && { dest="$d/_stacks"; break; }
      done
      if [[ -z "$dest" ]]; then
        dest="${HOME}/.zsh/completions/_stacks"
        mkdir -p "$(dirname "$dest")"
        info "Add to ~/.zshrc: ${BOLD}fpath=(~/.zsh/completions \$fpath)${RESET}"
        info "Then reload:     ${BOLD}autoload -U compinit && compinit${RESET}"
      fi
      ;;
  esac

  if $DRY_RUN; then
    info "[dry-run] Would write $shell completion to: $dest"
    return
  fi

  echo "$content" > "$dest"
  ok "$shell completion installed: $dest"
  [[ "$shell" == "bash" ]] && info "Reload: source $dest"
}

# ─── Help ─────────────────────────────────────────────────────────────────────

cmd_help() {
  cat << EOF
${BOLD}stacks.sh${RESET} v${VERSION}  —  Docker Compose stack manager

${BOLD}USAGE${RESET}
  ${CYAN}${SCRIPT_NAME}${RESET} [OPTIONS] COMMAND [FILTER...] [-- ARGS...]

${BOLD}GLOBAL OPTIONS${RESET}
  -n, --dry-run    Show commands without executing
  -p, --parallel   Run multi-stack operations in parallel
  -v, --verbose    Print each docker compose call before running
  -q, --quiet      Suppress informational output
  -h, --help       Show this help
      --version    Show version (${VERSION})

${BOLD}STACK COMMANDS${RESET}
  ${CYAN}ls${RESET}                         List stacks with live container status
  ${CYAN}ps${RESET}      [FILTER...]        Container status table
  ${CYAN}up${RESET}      [FILTER...]        Start stacks  (compose up -d)
  ${CYAN}down${RESET}    [FILTER...] [-v]   Stop stacks   (--volumes to also remove volumes)
  ${CYAN}restart${RESET} [FILTER...]        Restart stacks
  ${CYAN}pull${RESET}    [FILTER...]        Pull latest images
  ${CYAN}logs${RESET}    [FILTER...] [FLAGS]  Show logs (-f to follow, -n N for tail)
  ${CYAN}config${RESET}  [FILTER...]        Show resolved compose config
  ${CYAN}images${RESET}  [FILTER...]        Show images used by stacks
  ${CYAN}top${RESET}     [FILTER...]        Display running processes
  ${CYAN}stats${RESET}   [FILTER...]        Live container resource usage

${BOLD}SINGLE-STACK COMMANDS${RESET}
  ${CYAN}exec${RESET}    STACK SVC CMD...   Execute in a running container
  ${CYAN}run${RESET}     STACK SVC CMD...   Run a one-off command (--rm)
  ${CYAN}compose${RESET} STACK [CMD...]     Pass-through to docker compose

${BOLD}SERVICE MANAGEMENT${RESET}
  ${CYAN}update${RESET}  install    Link & enable systemd update timer
          uninstall  Disable & remove update timer
          run        Pull all images, recreate changed containers, prune
          status     Show timer/service status
          logs       Show journal logs
          next       Show next scheduled run
  ${CYAN}backup${RESET}  install    Link & enable systemd backup timer
          uninstall  Disable & remove backup timer
          run        Dump DBs + restic backup + prune snapshots
          status     Show timer/service status
          logs       Show journal logs
          next       Show next scheduled run
          snapshots  List restic snapshots

${BOLD}UTILITIES${RESET}
  ${CYAN}new${RESET} NAME [OPTIONS]         Scaffold a new stack
      --db postgres|mysql|mariadb   Add a database service
      --redis                        Add a Redis service
      --no-traefik                   Expose port instead of Traefik labels
  ${CYAN}completion${RESET} bash|zsh [--install] [--static]  Generate/install shell completion
               --static   Bake current stack list in (no runtime discovery)
  ${CYAN}version${RESET}                    Print version
  ${CYAN}help${RESET}                       Show this help

${BOLD}FILTER SYNTAX${RESET}
  One or more stack names or glob patterns, space- or comma-separated.
  Multiple filters are OR-merged and de-duplicated.
  Omit FILTER to match all stacks.

  Examples:
    gitea traefik         exact names
    'g*'                  glob: all stacks starting with g
    '*,!traefik'          (note: negation not supported — use exclusion manually)

${BOLD}EXAMPLES${RESET}
  ${SCRIPT_NAME} ls
  ${SCRIPT_NAME} up
  ${SCRIPT_NAME} up gitea traefik
  ${SCRIPT_NAME} down 'g*' --volumes
  ${SCRIPT_NAME} restart 'g*,traefik'
  ${SCRIPT_NAME} logs -f gitea
  ${SCRIPT_NAME} logs -n 100 gitea n8n
  ${SCRIPT_NAME} exec gitea gitea gitea admin user list
  ${SCRIPT_NAME} run passbolt passbolt bin/cake passbolt healthcheck
  ${SCRIPT_NAME} pull --parallel
  ${SCRIPT_NAME} pull --dry-run
  ${SCRIPT_NAME} update run
  ${SCRIPT_NAME} update install
  ${SCRIPT_NAME} backup run
  ${SCRIPT_NAME} backup snapshots
  ${SCRIPT_NAME} new myapp --db postgres --redis
  ${SCRIPT_NAME} completion zsh --install
EOF
}

# ─── Entry Point ──────────────────────────────────────────────────────────────

main() {
  local -a args=()

  # Parse global flags first (allow them anywhere before the command)
  for arg in "$@"; do
    case "$arg" in
      -n|--dry-run)    DRY_RUN=true ;;
      -p|--parallel)   PARALLEL=true ;;
      -v|--verbose)    VERBOSE=true ;;
      -q|--quiet)      QUIET=true ;;
      -h|--help)       cmd_help; exit 0 ;;
      --version)       echo "stacks.sh v$VERSION"; exit 0 ;;
      --)              break ;;
      *)               args+=("$arg") ;;
    esac
  done

  local command="${args[0]:-}"
  local -a rest=("${args[@]:1}")

  $DRY_RUN && warn "Dry-run mode — no changes will be made"

  case "$command" in
    ls|list)         cmd_ls ;;
    ps|status)       cmd_ps "${rest[@]+"${rest[@]}"}" ;;
    up|start)        cmd_up "${rest[@]+"${rest[@]}"}" ;;
    down|stop)       cmd_down "${rest[@]+"${rest[@]}"}" ;;
    restart)         cmd_restart "${rest[@]+"${rest[@]}"}" ;;
    pull)            cmd_pull "${rest[@]+"${rest[@]}"}" ;;
    logs|log)        cmd_logs "${rest[@]+"${rest[@]}"}" ;;
    exec)            cmd_exec "${rest[@]+"${rest[@]}"}" ;;
    run)             cmd_run_svc "${rest[@]+"${rest[@]}"}" ;;
    config)          cmd_config "${rest[@]+"${rest[@]}"}" ;;
    images)          cmd_images "${rest[@]+"${rest[@]}"}" ;;
    top)             cmd_top "${rest[@]+"${rest[@]}"}" ;;
    stats)           cmd_stats "${rest[@]+"${rest[@]}"}" ;;
    compose)         cmd_compose "${rest[@]+"${rest[@]}"}" ;;
    update)          cmd_update "${rest[@]+"${rest[@]}"}" ;;
    backup)          cmd_backup "${rest[@]+"${rest[@]}"}" ;;
    new|scaffold)    cmd_new "${rest[@]+"${rest[@]}"}" ;;
    completion)      cmd_completion "${rest[@]+"${rest[@]}"}" ;;
    version)         echo "stacks.sh v$VERSION" ;;
    help|"")         cmd_help ;;
    *)
      err "Unknown command: ${BOLD}$command${RESET}"
      echo -e "  Run ${CYAN}$SCRIPT_NAME help${RESET} for usage."
      exit 1
      ;;
  esac
}

main "$@"
