#!/usr/bin/env bash
# DevGrail uninstaller — companion to install.sh.
#
#   curl -fsSL https://<devgrail-web>/uninstall.sh | sudo bash              # preserve data
#   curl -fsSL https://<devgrail-web>/uninstall.sh | sudo bash -s -- --purge # full wipe
#   sudo bash uninstall.sh            # tear down, PRESERVE data volumes + config
#   sudo bash uninstall.sh --purge    # full wipe: also remove data, certs, files
#   sudo bash uninstall.sh --nuke     # --purge, plus undo the host-level changes
#                                     # install.sh made, so the VPS is as it was
#
# --purge takes a backup (database + config.yaml) before deleting anything, so
# the one irreversible command in the product still leaves a way back. Pass
# --no-backup to skip it — which is the only way to destroy the data outright.
#
# Removes what install.sh created: the Compose stack (infra containers +
# managed networks + volumes), the separately-spawned workspace containers,
# networks and volumes (labeled `devgrail.managed`, not owned by Compose), the
# external `devgrail` edge network, the loaded images (every tag, not just
# :latest) and — with --purge — the persistent data volumes and the on-disk
# config in /opt/devgrail and /etc/devgrail.
#
# Default run preserves the SQLite data volume (devgrail-data), the Let's Encrypt
# certs (traefik-acme), all workspace volumes, and the config files, so a later
# re-run of install.sh resumes in place. --purge leaves nothing of DevGrail's own.
#
# --purge still leaves the host itself changed, because install.sh changes more
# than DevGrail's own namespace: it edits /etc/docker/daemon.json (userns-remap,
# default-address-pools), which moves Docker's data root and creates the
# `dockremap` user, and it installs Docker and jq. --nuke reverts all of that
# except the packages; --remove-docker additionally uninstalls Docker Engine.
# Neither ever removes openssl — too much on a VPS depends on it.
#
# Vars:
#   DEVGRAIL_INSTALL_DIR  install root (default /opt/devgrail)
#   DEVGRAIL_DAEMON_JSON  Docker daemon config to revert (default /etc/docker/daemon.json)
set -euo pipefail

INSTALL_DIR="${DEVGRAIL_INSTALL_DIR:-/opt/devgrail}"
DEPLOY_DIR="$INSTALL_DIR/deploy"
CONFIG_DIR=/etc/devgrail
COMPOSE_FILE="$DEPLOY_DIR/docker-compose.yml"
OVERRIDE_FILE="$DEPLOY_DIR/docker-compose.override.yml"
# Compose derives the project name from the project directory, so this is the
# prefix on every object the stack owns (deploy_devgrail-data, deploy-traefik-1).
# It is how orphans are found once $COMPOSE_FILE itself is gone.
PROJECT="$(basename "$DEPLOY_DIR")"
DAEMON_JSON="${DEVGRAIL_DAEMON_JSON:-/etc/docker/daemon.json}"

PURGE=0
ASSUME_YES=0
# --nuke implies --purge and additionally reverts the host-level changes;
# --remove-docker implies --nuke and additionally uninstalls Docker Engine.
NUKE=0
REMOVE_DOCKER=0
# A --purge takes a backup first unless the operator explicitly opts out. It is
# the only irreversible operation here: the database, every workspace volume and
# the config.yaml whose jwt_secret decrypts the database all go at once.
NO_BACKUP=0

log()  { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARNING:\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }

usage() {
  cat <<EOF
Usage: sudo bash uninstall.sh [--purge] [--nuke] [--remove-docker] [--no-backup] [--yes]

  --purge   Also remove persistent data (devgrail-data volume, traefik-acme
            certs, workspace volumes) and the on-disk config in $INSTALL_DIR
            and $CONFIG_DIR. Without this, those are preserved so install.sh
            can resume in place.
  --nuke    Implies --purge, and additionally undoes what install.sh changed
            outside DevGrail's own namespace: the userns-remap and
            default-address-pools keys it added to $DAEMON_JSON
            (restarting Docker), the now-orphaned remapped data root, and the
            dockremap user. Leaves the host as install.sh found it, so a
            re-run installs from scratch. Docker, jq and openssl stay.
  --remove-docker
            Implies --nuke, and also uninstalls Docker Engine, containerd and
            /var/lib/docker. This stops and deletes EVERY container on this
            host, DevGrail's or not. Only for a host that is DevGrail's alone.
  --yes     Do not prompt for confirmation.
  --no-backup
            With --purge, skip the automatic pre-wipe backup. Without this,
            --purge writes a database + config.yaml archive to
            $INSTALL_DIR/backups first and refuses to continue if it cannot.
  -h, --help  Show this help.
EOF
}

while [ $# -gt 0 ]; do
  case "$1" in
    --purge)     PURGE=1 ;;
    --nuke)      NUKE=1; PURGE=1 ;;
    --remove-docker) REMOVE_DOCKER=1; NUKE=1; PURGE=1 ;;
    --no-backup) NO_BACKUP=1 ;;
    --yes|-y)    ASSUME_YES=1 ;;
    -h|--help)   usage; exit 0 ;;
    *) die "unknown argument: $1 (see --help)" ;;
  esac
  shift
done

[ "$(id -u)" = 0 ] || die "run as root: sudo bash uninstall.sh"

# A host with no Docker has no DevGrail objects to remove, but it may still
# carry the daemon config and the dockremap user install.sh left behind — so
# --nuke still has work to do, and only the Docker-object steps are skipped.
HAVE_DOCKER=1
command -v docker >/dev/null 2>&1 || HAVE_DOCKER=0
if [ "$HAVE_DOCKER" = 0 ] && [ "$NUKE" = 0 ]; then
  die "docker not found — nothing to do, or already removed"
fi

# compose runs the stack's own compose file, including the operator's override
# if there is one. Matching install.sh matters here: an override that adds a
# service means `down` without it leaves that service running.
compose() {
  if [ -f "$OVERRIDE_FILE" ]; then
    docker compose --project-directory "$DEPLOY_DIR" -f "$COMPOSE_FILE" -f "$OVERRIDE_FILE" "$@"
  else
    docker compose --project-directory "$DEPLOY_DIR" -f "$COMPOSE_FILE" "$@"
  fi
}

# --- confirm + decide whether to wipe data ---------------------------------
# stdin is the piped installer, so questions go to /dev/tty. Returns 0 on yes.
ask() {
  local msg="$1" ans
  printf '%s ' "$msg" > /dev/tty
  read -r ans < /dev/tty || ans=""
  case "$ans" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
}

if [ "$ASSUME_YES" = 1 ]; then
  : # honour --purge/flags as given, no prompts
elif [ -e /dev/tty ]; then
  if [ "$PURGE" = 1 ]; then
    warn "PURGE mode: this permanently deletes the DevGrail database, TLS certs,"
    warn "all workspace volumes, and config in $INSTALL_DIR and $CONFIG_DIR."
    ask "Permanently delete everything? [y/N]" || die "aborted"
  else
    # Preserve is the default; offer the wipe rather than requiring the flag.
    if ask "Also delete ALL DevGrail data — workspaces, database, certs, config? [y/N]"; then
      PURGE=1
    fi
  fi

  # Offered separately, because it is a different blast radius: everything above
  # is DevGrail's own, everything here is the host's.
  if [ "$PURGE" = 1 ] && [ "$NUKE" = 0 ]; then
    echo >&2
    echo "  install.sh also changed this host outside DevGrail's own namespace:" >&2
    echo "  the userns-remap / default-address-pools keys in $DAEMON_JSON," >&2
    echo "  Docker's data root, and the dockremap user. Reverting those restarts" >&2
    echo "  the Docker daemon, and is what makes a later install.sh run behave as" >&2
    echo "  if this VPS were new." >&2
    echo >&2
    if ask "Also revert those host-level changes? [y/N]"; then NUKE=1; fi
  fi

  if [ "$NUKE" = 1 ] && [ "$REMOVE_DOCKER" = 0 ]; then
    echo >&2
    warn "The next question is not limited to DevGrail."
    echo "  Uninstalling Docker Engine stops and deletes EVERY container, image" >&2
    echo "  and volume on this host, including anything unrelated to DevGrail," >&2
    echo "  and removes /var/lib/docker. Say no if this VPS runs anything else." >&2
    echo >&2
    if ask "Also uninstall Docker Engine itself? [y/N]"; then REMOVE_DOCKER=1; fi
  fi
else
  # No terminal: never prompt. Preserve is safe to run unattended; a wipe is not,
  # so --purge without a terminal must be confirmed with --yes. --nuke and
  # --remove-docker imply --purge, so this covers them too.
  [ "$PURGE" = 1 ] && die "refusing to --purge without a terminal — pass --yes to confirm"
fi

if [ "$REMOVE_DOCKER" = 1 ]; then
  log "Uninstalling DevGrail — FULL WIPE, host changes reverted, Docker removed."
elif [ "$NUKE" = 1 ]; then
  log "Uninstalling DevGrail — FULL WIPE, and reverting install.sh's host changes."
elif [ "$PURGE" = 1 ]; then
  log "Uninstalling DevGrail — FULL WIPE (data, certs, and config will be removed)."
else
  log "Uninstalling DevGrail (data volumes + config preserved; use --purge to wipe)."
fi

# --- host revert helpers ---------------------------------------------------
# Extracted by test/installer/uninstall_nuke_test.sh between this marker and the
# matching "end host revert helpers" one. Keep both comments intact, and keep
# everything between them free of top-level side effects so the tests can source
# it on a host with no Docker and no root.

# Restart dockerd across the init systems install.sh supports: systemd on the
# Debian/RHEL images, OpenRC on Alpine. Non-zero means no known mechanism
# worked, which callers treat as "put the config back".
restart_docker() {
  if command -v systemctl >/dev/null 2>&1; then
    systemctl restart docker >/dev/null 2>&1 && return 0
  fi
  if command -v rc-service >/dev/null 2>&1; then
    rc-service docker restart >/dev/null 2>&1 && return 0
  fi
  if command -v service >/dev/null 2>&1; then
    service docker restart >/dev/null 2>&1 && return 0
  fi
  return 1
}

# Wait for the daemon to answer again. Coming off a remapped data root it has a
# different tree to re-index, so this window is generous on purpose.
wait_for_docker() {
  local i=0
  while [ "$i" -lt 90 ]; do
    docker info >/dev/null 2>&1 && return 0
    sleep 1
    i=$((i + 1))
  done
  return 1
}

# Which of install.sh's keys this run actually removed. Read afterwards by the
# data-root and dockremap cleanup, which must only run when userns-remap was
# genuinely ours to turn off.
REVERTED_USERNS=0
REVERTED_POOLS=0

# revert_daemon_json — drop the two keys install.sh adds to $DAEMON_JSON and
# restart the daemon. Returns 0 only if something was changed and Docker came
# back on the new config.
#
# Keys are removed only while they still hold the exact values install.sh
# writes. An operator who has since set userns-remap to a named user, or tuned
# the pools to their own addressing, made a decision this script has no business
# reversing — those are reported and left. A daemon.json that does not parse is
# never touched, for the same reason install.sh does not touch it: that is an
# operator edit in progress, and clobbering it would destroy work we cannot see.
revert_daemon_json() {
  local current merged filter backup stamp remap

  REVERTED_USERNS=0
  REVERTED_POOLS=0

  if [ ! -s "$DAEMON_JSON" ]; then
    log "No $DAEMON_JSON on this host — nothing to revert."
    return 1
  fi
  if ! jq -e . "$DAEMON_JSON" >/dev/null 2>&1; then
    warn "$DAEMON_JSON exists but is not valid JSON — leaving it alone."
    echo "  Remove the userns-remap and default-address-pools keys by hand." >&2
    return 1
  fi
  current="$(cat "$DAEMON_JSON")"

  remap="$(printf '%s' "$current" | jq -r '."userns-remap" // empty')"
  case "$remap" in
    default) REVERTED_USERNS=1 ;;
    '')      ;;
    *)       warn "$DAEMON_JSON sets userns-remap to \"$remap\", which install.sh never writes — leaving it." ;;
  esac

  # The two candidate ranges install.sh offers, each carved into a /24 (see its
  # "default address pools" section). Anything else is the operator's own
  # addressing plan, and narrowing or widening it is their call.
  if printf '%s' "$current" | jq -e '
        ."default-address-pools" as $p
        | ($p | type) == "array"
          and ($p | length) == 1
          and ($p[0].size == 24)
          and ($p[0].base == "172.16.0.0/12" or $p[0].base == "10.201.0.0/16")' >/dev/null 2>&1; then
    REVERTED_POOLS=1
  elif printf '%s' "$current" | jq -e 'has("default-address-pools")' >/dev/null 2>&1; then
    warn "$DAEMON_JSON has default-address-pools install.sh did not write — leaving them."
  fi

  if [ "$REVERTED_USERNS" = 0 ] && [ "$REVERTED_POOLS" = 0 ]; then
    log "Nothing in $DAEMON_JSON came from install.sh — leaving it untouched."
    return 1
  fi

  filter='.'
  [ "$REVERTED_USERNS" = 1 ] && filter="$filter | del(.\"userns-remap\")"
  [ "$REVERTED_POOLS" = 1 ]  && filter="$filter | del(.\"default-address-pools\")"

  if ! merged="$(printf '%s' "$current" | jq "$filter" 2>/dev/null)" || [ -z "$merged" ]; then
    warn "could not compute the reverted $DAEMON_JSON — leaving it alone."
    REVERTED_USERNS=0; REVERTED_POOLS=0
    return 1
  fi

  # The file is left as {} rather than deleted when it empties out: whether it
  # predated the install is unknowable from here, and an empty object is inert.
  stamp="$(date +%Y%m%d%H%M%S)"
  backup="$DAEMON_JSON.devgrail-revert-$stamp"
  if ! cp -p "$DAEMON_JSON" "$backup"; then
    warn "could not back up $DAEMON_JSON — not changing it."
    REVERTED_USERNS=0; REVERTED_POOLS=0
    return 1
  fi
  if ! printf '%s\n' "$merged" > "$DAEMON_JSON.devgrail-new"; then
    warn "could not write $DAEMON_JSON — leaving it alone."
    rm -f "$backup" "$DAEMON_JSON.devgrail-new"
    REVERTED_USERNS=0; REVERTED_POOLS=0
    return 1
  fi
  chmod 0644 "$DAEMON_JSON.devgrail-new"
  mv -f "$DAEMON_JSON.devgrail-new" "$DAEMON_JSON"

  # No daemon to restart — the file is the whole change. This is the ordinary
  # case when --nuke follows an earlier --remove-docker, and treating it as a
  # failed restart would restore the keys and abort over nothing.
  if ! command -v docker >/dev/null 2>&1; then
    log "Docker is not installed on this host, so there is no daemon to restart."
    rm -f "$backup"
    return 0
  fi

  log "Restarting Docker on the reverted daemon config..."
  if restart_docker && wait_for_docker; then
    rm -f "$backup"
    return 0
  fi

  warn "Docker did not come back after the revert — restoring the previous $DAEMON_JSON."
  mv -f "$backup" "$DAEMON_JSON"
  REVERTED_USERNS=0; REVERTED_POOLS=0
  if restart_docker && wait_for_docker; then
    warn "Docker is back on its previous configuration; $DAEMON_JSON was NOT reverted."
    return 1
  fi
  die "Docker is not responding and restoring the previous configuration did not revive it.
  $DAEMON_JSON holds what it held before this run. Investigate with:
    journalctl -u docker --no-pager -n 50"
}

# remapped_root_reclaimable <prior_root> <current_root> — true when <prior_root>
# is the per-UID data root userns-remap was using and the daemon has since moved
# off it, leaving that whole tree unreachable.
#
# Deliberately narrow: the path must be a direct child of the daemon's current
# root, named <uid>.<gid>, and still exist. Anything else — a configured
# data-root, a bind mount, an unreadable `docker info` — is not a path to hand
# to `rm -rf` on a guess.
remapped_root_reclaimable() {
  local prior="$1" now="$2" base
  [ -n "$prior" ] && [ -n "$now" ] || return 1
  [ "$prior" != "$now" ] || return 1
  [ "$(dirname "$prior")" = "$now" ] || return 1
  base="$(basename "$prior")"
  case "$base" in
    *[!0-9.]*) return 1 ;;   # digits and dots only
    .*|*.)     return 1 ;;   # no leading or trailing dot
    *.*.*)     return 1 ;;   # exactly one dot
    *.*)       ;;
    *)         return 1 ;;   # ...and it must have one
  esac
  [ -d "$prior" ]
}

# Drop the subordinate-id ranges dockerd allocated for its remap user, and the
# user itself. Only called when userns-remap was actually reverted, so this
# never removes a dockremap the operator still needs. /etc/subuid and
# /etc/subgid themselves stay: install.sh only ever creates them empty, which is
# indistinguishable from a stock host.
remove_dockremap() {
  local f tmpf
  for f in /etc/subuid /etc/subgid; do
    [ -f "$f" ] || continue
    grep -q '^dockremap:' "$f" 2>/dev/null || continue
    tmpf="$f.devgrail-tmp"
    { grep -v '^dockremap:' "$f" || true; } > "$tmpf"
    # Written back through the original inode so ownership and mode survive.
    cat "$tmpf" > "$f"
    rm -f "$tmpf"
    log "Removed the dockremap ranges from $f"
  done
  if command -v userdel >/dev/null 2>&1 && id dockremap >/dev/null 2>&1; then
    userdel dockremap >/dev/null 2>&1 && log "Removed the dockremap user." || true
  fi
  return 0
}
# --- end host revert helpers -----------------------------------------------

# compose_project_containers — ids of containers belonging to *this* install's
# Compose project. The project label alone is not enough (it is just the deploy
# directory's basename, which another stack could share), so each candidate is
# confirmed against the config-file path Compose records on it.
compose_project_containers() {
  local id conf
  for id in $({ docker ps -aq --filter "label=com.docker.compose.project=$PROJECT" 2>/dev/null || true; }); do
    conf="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$id" 2>/dev/null || true)"
    case "$conf" in
      "$DEPLOY_DIR"/*) printf '%s\n' "$id" ;;
    esac
  done
}

# compose_project_object <volume|network> <compose name> — the project's copy of
# a volume or network declared in docker-compose.yml. Volumes and networks carry
# no config_files label, so identity comes from the project label plus Compose's
# own name label; an unrelated stack would have to match both exactly.
compose_project_object() {
  local kind="$1" name="$2"
  docker "$kind" ls -q \
    --filter "label=com.docker.compose.project=$PROJECT" \
    --filter "label=com.docker.compose.$kind=$name" 2>/dev/null || true
}

# image_refs <reference> — every repo:tag currently pointing into <reference>.
# install.sh leaves three per image (:latest, the displaced build as :previous,
# and the release tag `docker load` unpacked), so removing :latest by name used
# to leave two behind.
#
# Every stage is guarded: on a host where the images are already gone, `grep -v`
# matches nothing and exits 1, which under `set -o pipefail` would surface as a
# failed assignment and kill the script mid-uninstall.
image_refs() {
  { docker images --filter "reference=$1" --format '{{.Repository}}:{{.Tag}}' 2>/dev/null || true; } \
    | { grep -v ':<none>$' || true; } | sort -u
}

remove_image_refs() {
  local ref refs
  for ref in "$@"; do
    refs="$(image_refs "$ref")"
    [ -n "$refs" ] || continue
    # shellcheck disable=SC2086 # deliberate word splitting over the ref list
    docker rmi $refs >/dev/null 2>&1 || true
  done
}

[ "$HAVE_DOCKER" = 0 ] && warn "docker is not installed — skipping every container, volume and image step."

# --- 0. pre-wipe backup (purge only) ---------------------------------------
# Taken before anything is torn down, while the server is still up and the
# maintenance CLI can still resolve the data volume. A failure here aborts:
# proceeding would destroy the data this backup exists to preserve, and an
# operator who genuinely wants that has --no-backup to say so.
if [ "$PURGE" = 1 ] && [ "$NO_BACKUP" = 0 ] && [ "$HAVE_DOCKER" = 1 ]; then
  if [ -x /usr/local/bin/devgrail ]; then
    log "Taking a final backup before wiping..."
    /usr/local/bin/devgrail backup || die "the pre-wipe backup failed — refusing to destroy data that has not been backed up.
  Fix the cause and re-run, or pass --no-backup to wipe anyway."
    log "Backup kept in $INSTALL_DIR/backups — it survives this uninstall only if you copy it off this host first."
  else
    warn "no /usr/local/bin/devgrail on this host, so no pre-wipe backup can be taken."
    warn "This install predates the maintenance CLI. Re-run install.sh to get it, or"
    warn "pass --no-backup to confirm you are destroying the data unrecoverably."
    [ "$ASSUME_YES" = 1 ] || die "aborted (pass --no-backup to wipe without a backup)"
  fi
fi

# The daemon's data root, read while userns-remap is still in effect. Under
# remap it is a per-UID subdirectory; once the revert in step 5 moves the daemon
# off it, that tree is unreachable disk with nothing left to read it.
PRIOR_DOCKER_ROOT=""
if [ "$NUKE" = 1 ] && [ "$HAVE_DOCKER" = 1 ]; then
  PRIOR_DOCKER_ROOT="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
fi

if [ "$HAVE_DOCKER" = 1 ]; then

# --- 1. tear down the Compose stack ---------------------------------------
# Infra containers (traefik, devgrail-server, docker-socket-proxy, and the exited
# devgrail-data-init one-shot), the managed devgrail-control/devgrail-docker
# networks, and — with --purge — the named volumes (devgrail-data, traefik-acme).
if [ -f "$COMPOSE_FILE" ] && docker compose version >/dev/null 2>&1; then
  log "Stopping the DevGrail Compose stack..."
  if [ "$PURGE" = 1 ]; then
    compose down -v --remove-orphans || warn "compose down reported an error — continuing"
  else
    compose down --remove-orphans || warn "compose down reported an error — continuing"
  fi
else
  warn "no compose file at $COMPOSE_FILE — removing the stack's objects directly"
fi

# Belt and braces, and the only path that works at all once $COMPOSE_FILE has
# been deleted by an earlier --purge: sweep whatever the stack still owns.
# Traefik is why this cannot be a list of names — it declares no container_name,
# so it is "<project>-traefik-1", and the old name-based fallback never touched
# it (nor the project's volumes and networks).
orphans="$(compose_project_containers)"
if [ -n "$orphans" ]; then
  log "Removing leftover Compose containers..."
  # shellcheck disable=SC2086 # deliberate word splitting over the id list
  docker rm -f $orphans >/dev/null 2>&1 || true
fi
for net in devgrail-control devgrail-docker; do
  ids="$(compose_project_object network "$net")"
  # shellcheck disable=SC2086
  [ -n "$ids" ] && docker network rm $ids >/dev/null 2>&1 || true
done
if [ "$PURGE" = 1 ]; then
  for vol in devgrail-data traefik-acme; do
    ids="$(compose_project_object volume "$vol")"
    # shellcheck disable=SC2086
    [ -n "$ids" ] && docker volume rm $ids >/dev/null 2>&1 || true
  done
fi

# --- 2. workspace containers, networks + volumes (labeled, not Compose-owned) --
log "Removing workspace containers..."
ws_containers="$(docker ps -aq --filter label=devgrail.managed 2>/dev/null || true)"
# shellcheck disable=SC2086
[ -n "$ws_containers" ] && docker rm -f $ws_containers >/dev/null 2>&1 || true

# Removed in both modes, like the containers above. A per-workspace network
# holds no state — the server recreates it, and re-attaches Traefik, the next
# time the workspace starts — but one left behind holds an address-pool slot,
# and is enough to make install.sh see this as a host with Docker state.
ws_networks="$(docker network ls -q --filter label=devgrail.managed 2>/dev/null || true)"
if [ -n "$ws_networks" ]; then
  log "Removing workspace networks..."
  # shellcheck disable=SC2086
  docker network rm $ws_networks >/dev/null 2>&1 || true
fi

if [ "$PURGE" = 1 ]; then
  log "Removing workspace volumes..."
  ws_volumes="$(docker volume ls -q --filter label=devgrail.managed 2>/dev/null || true)"
  # shellcheck disable=SC2086
  [ -n "$ws_volumes" ] && docker volume rm $ws_volumes >/dev/null 2>&1 || true
else
  ws_volumes="$(docker volume ls -q --filter label=devgrail.managed 2>/dev/null || true)"
  [ -n "$ws_volumes" ] && log "Preserved $(printf '%s\n' "$ws_volumes" | grep -c .) workspace volume(s) (use --purge to remove)."
fi

# --- 3. external edge network ----------------------------------------------
# Created out-of-band by install.sh (external: true in the compose), so `down`
# never removes it. Only removable once all attached containers are gone.
if docker network inspect devgrail >/dev/null 2>&1; then
  log "Removing the devgrail edge network..."
  docker network rm devgrail >/dev/null 2>&1 || \
    warn "could not remove the 'devgrail' network (still in use?) — remove manually once containers are gone"
fi

# --- 4. images -------------------------------------------------------------
# Every tag, not only :latest — see image_refs.
log "Removing DevGrail images..."
remove_image_refs devgrail-server devgrail-container
# Third-party support images pulled by the stack. Left in place unless purging,
# since they're shared/base images that may be reused.
if [ "$NUKE" = 1 ]; then
  # --nuke returns the host to its pre-install state, so superseded pins from
  # earlier releases go too: one image left behind anywhere is enough to make
  # install.sh refuse to offer userns-remap on the next run.
  remove_image_refs traefik tecnativa/docker-socket-proxy
  docker image prune -f >/dev/null 2>&1 || true
elif [ "$PURGE" = 1 ]; then
  docker rmi tecnativa/docker-socket-proxy:v0.4.2 traefik:v3.7.6 >/dev/null 2>&1 || true
fi

fi  # HAVE_DOCKER

# --- 5. host-level changes (nuke only) -------------------------------------
# After every Docker object is gone, and before the on-disk config: under
# userns-remap the daemon's data root is a per-UID subdirectory, so reverting
# first would hide DevGrail's own volumes and images behind a root nothing can
# reach, leaving them undeletable.
#
# Run even when Docker is about to be removed outright. It costs one restart,
# and it means a host left half-done by a failed package removal is still
# coherent rather than pointing at a data root that no longer exists.
if [ "$NUKE" = 1 ]; then
  log "Reverting install.sh's changes to $DAEMON_JSON..."
  if ! command -v jq >/dev/null 2>&1; then
    warn "jq is not installed, so $DAEMON_JSON cannot be edited safely — skipping."
    echo "  Remove these keys by hand and restart Docker:" >&2
    echo "    \"userns-remap\": \"default\"" >&2
    echo "    \"default-address-pools\": [...]" >&2
  elif revert_daemon_json; then
    if [ "$REVERTED_USERNS" = 1 ]; then
      now_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
      if remapped_root_reclaimable "$PRIOR_DOCKER_ROOT" "$now_root"; then
        log "Reclaiming the orphaned remapped data root $PRIOR_DOCKER_ROOT..."
        rm -rf "$PRIOR_DOCKER_ROOT"
      elif [ -d "$PRIOR_DOCKER_ROOT" ] && [ "$PRIOR_DOCKER_ROOT" != "$now_root" ]; then
        warn "Docker's previous data root was $PRIOR_DOCKER_ROOT, which does not look"
        warn "like a userns-remap root — leaving it. Remove it by hand if it is stale."
      fi
      remove_dockremap
    fi
    log "$DAEMON_JSON reverted."
  fi
  # The installer's own backups of the file, unambiguously ours.
  rm -f "$DAEMON_JSON".devgrail-bak-* 2>/dev/null || true
fi

# --- 6. on-disk config (purge only) ----------------------------------------
if [ "$PURGE" = 1 ]; then
  log "Removing config and deploy files..."
  # $INSTALL_DIR holds the backups directory, including the one just taken —
  # say where it went before it goes, rather than silently deleting the only
  # copy of the data seconds after making it.
  if [ "$NO_BACKUP" = 0 ] && [ -d "$INSTALL_DIR/backups" ]; then
    warn "Deleting $INSTALL_DIR/backups along with the rest of $INSTALL_DIR."
    warn "If you want the pre-wipe backup, Ctrl-C now and copy it off this host."
  fi
  rm -rf "$INSTALL_DIR" "$CONFIG_DIR"
  rm -f /usr/local/bin/devgrail
  if command -v systemctl >/dev/null 2>&1; then
    systemctl disable --now devgrail-backup.timer >/dev/null 2>&1 || true
    rm -f /etc/systemd/system/devgrail-backup.timer /etc/systemd/system/devgrail-backup.service
    systemctl daemon-reload >/dev/null 2>&1 || true
  fi
fi

# --- 7. Docker Engine (--remove-docker only) -------------------------------
# Last, because everything above needs a working daemon. Best-effort throughout:
# by this point DevGrail is already gone, and a failure here should report what
# is left rather than abort an uninstall that has already succeeded.
if [ "$REMOVE_DOCKER" = 1 ] && [ "$HAVE_DOCKER" = 1 ]; then
  log "Uninstalling Docker Engine..."
  if command -v systemctl >/dev/null 2>&1; then
    systemctl disable --now docker.socket >/dev/null 2>&1 || true
    systemctl disable --now docker >/dev/null 2>&1 || true
    systemctl disable --now containerd >/dev/null 2>&1 || true
  elif command -v rc-service >/dev/null 2>&1; then
    rc-service docker stop >/dev/null 2>&1 || true
  elif command -v service >/dev/null 2>&1; then
    service docker stop >/dev/null 2>&1 || true
  fi

  # The package set get.docker.com installs. Missing ones are not an error — a
  # host that got Docker some other way simply has fewer of them.
  DOCKER_PKGS="docker-ce docker-ce-cli docker-ce-rootless-extras containerd.io docker-buildx-plugin docker-compose-plugin"
  # shellcheck disable=SC2086 # deliberate word splitting over the package list
  if command -v apt-get >/dev/null 2>&1; then
    DEBIAN_FRONTEND=noninteractive apt-get purge -y $DOCKER_PKGS >/dev/null 2>&1 || true
    DEBIAN_FRONTEND=noninteractive apt-get autoremove -y >/dev/null 2>&1 || true
    rm -f /etc/apt/sources.list.d/docker.list /etc/apt/keyrings/docker.asc /etc/apt/keyrings/docker.gpg
  elif command -v dnf >/dev/null 2>&1; then
    dnf remove -y $DOCKER_PKGS >/dev/null 2>&1 || true
    rm -f /etc/yum.repos.d/docker-ce.repo
  elif command -v yum >/dev/null 2>&1; then
    yum remove -y $DOCKER_PKGS >/dev/null 2>&1 || true
    rm -f /etc/yum.repos.d/docker-ce.repo
  elif command -v apk >/dev/null 2>&1; then
    apk del docker docker-cli docker-engine docker-cli-compose containerd >/dev/null 2>&1 || true
  else
    warn "no known package manager — Docker's packages were not removed."
    echo "  Remove them by hand; the directories below are cleared regardless." >&2
  fi

  rm -rf /var/lib/docker /var/lib/containerd /etc/docker
  command -v groupdel >/dev/null 2>&1 && groupdel docker >/dev/null 2>&1 || true
  if command -v docker >/dev/null 2>&1; then
    warn "a 'docker' binary is still on PATH at $(command -v docker) — remove it by hand."
  else
    HAVE_DOCKER=0
    log "Docker Engine removed."
  fi
fi

cat <<EOF

============================================================
  DevGrail has been uninstalled.
EOF
if [ "$NUKE" = 1 ]; then
  cat <<EOF

  Full wipe complete — data volumes, certs, images and config
  removed, and install.sh's host changes reverted. A re-run of
  install.sh will install from scratch, as on a new VPS.
EOF
elif [ "$PURGE" = 1 ]; then
  cat <<EOF

  Full wipe complete — data volumes, certs, and config removed.
EOF
else
  cat <<EOF

  Data preserved: the devgrail-data + traefik-acme volumes, any
  workspace volumes, and config in $INSTALL_DIR / $CONFIG_DIR
  were kept. Re-run install.sh to resume, or re-run this with
  --purge to delete everything.
EOF
fi
if [ "$REMOVE_DOCKER" = 1 ]; then
  cat <<EOF

  Docker Engine was uninstalled. jq and openssl were left
  installed — openssl in particular is depended on by much of
  the system, so this script never removes it.
============================================================
EOF
elif [ "$NUKE" = 1 ]; then
  cat <<EOF

  Docker, jq and openssl were left installed. Pass
  --remove-docker to uninstall Docker Engine as well.
============================================================
EOF
else
  cat <<EOF

  Docker and jq were left installed. userns-remap in
  $DAEMON_JSON (if you enabled it) was left unchanged;
  pass --nuke to revert it.
============================================================
EOF
fi
