#!/usr/bin/env bash
# DevGrail uninstaller — companion to install.sh.
#
#   curl -fsSL https://<devgrail-web>/uninstall.sh | sudo bash              # keep workspaces + data
#   curl -fsSL https://<devgrail-web>/uninstall.sh | sudo bash -s -- --purge # full wipe
#   sudo bash uninstall.sh            # tear down, PRESERVE workspaces, data + config
#   sudo bash uninstall.sh --purge    # full wipe: also remove data, certs, files
#   sudo bash uninstall.sh --keep-workspaces
#                                     # remove all of DevGrail's own state, but
#                                     # leave the workspace containers, their
#                                     # volumes and their networks on the host
#   sudo bash uninstall.sh --nuke     # --purge, plus undo the host-level changes
#                                     # install.sh made, so the VPS is as it was
#   sudo bash uninstall.sh --dry-run  # report what would be destroyed, change nothing
#
# On a terminal you do not need the flags: a plain run asks what to remove, one
# part at a time — the database and config, the workspaces, their snapshots,
# the TLS certificates, the downloaded images, the backup archives, whether to
# take a backup first, install.sh's host changes and Docker Engine — so "remove
# everything but the workspaces" is a yes and a no. Every question defaults to
# keeping, and is skipped when a flag or the host has already answered it. It
# then shows the resulting plan and asks once to go ahead. Each answer has a
# flag, so an unattended run (--yes, or no terminal) can say the same thing.
#
# Every run opens with an inventory of what it is about to destroy and what it
# will keep, named object by object, before the first question is asked.
# --dry-run prints that same report and stops there. It is the safe first move
# on a host you are not certain about, and it combines with the flags below:
# `--nuke --dry-run` reports what a --nuke would do without doing any of it.
#
# --keep-workspaces is a --purge with one carve-out. Everything of DevGrail's
# own goes — the stack, the database, the certificates, the images and the
# config on disk — while the workspace containers, their volumes, their
# per-workspace networks and the image they run on stay where they are. It is
# the mode for starting the install over without throwing the work away: a fresh
# install.sh finds those containers again and the dashboard's Re-add tab offers
# them back with their files, volume and ports intact. What it cannot give back
# is what only ever lived in the database — a workspace's name, description,
# documentation, project, app subdomains and API-key grants, and its SSH
# password, which is re-generated. Restoring the pre-wipe backup onto the new
# install is what brings those back. It is refused with --nuke and
# --remove-docker, both of which destroy the containers it exists to keep.
#
# --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 external `devgrail` edge network, the loaded
# images (every tag, not just :latest) and — with --purge — the workspace
# containers, their separately-spawned per-workspace networks and their volumes
# (labeled `devgrail.managed`, not owned by Compose), the persistent data volumes
# and the on-disk config in /opt/devgrail and /etc/devgrail.
#
# Default run deletes nothing of yours. The workspace containers are stopped and
# left where they are — a workspace's filesystem is not all in its volume, so
# removing the container would throw away installed packages, shell history and
# whatever else lives in its writable layer — and so are their volumes, their
# per-workspace networks (a stopped container is bound to its network by id, so
# removing one would strand it), the devgrail-container image they run on, the
# SQLite data volume (devgrail-data), the Let's Encrypt certs (traefik-acme) and
# the config files. Only the stack containers go, and install.sh recreates those,
# so a later re-run resumes in place with every workspace intact.
# --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}"
# The maintenance CLI install.sh writes, which takes the pre-wipe backup.
DEVGRAIL_CLI=/usr/local/bin/devgrail

PURGE=0
ASSUME_YES=0
# Report and stop. Nothing below this flag's check may write, delete or restart
# anything, which is what lets --dry-run skip the no-terminal guard entirely.
DRY_RUN=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
# --keep-workspaces: a --purge that stops short of the workspaces themselves.
KEEP_WORKSPACES=0
# The finer carve-outs of a --purge, one per part that can outlive it. Empty
# means "not decided": the questions ask about exactly those, and refresh_modes
# reads empty as the --purge default (it goes), so an unattended --purge removes
# what it always has.
#   KEEP_CERTS       the traefik-acme volume, so a reinstall on the same
#                    hostname does not order new certificates
#   KEEP_IMAGES      every image this run would remove, so a reinstall does not
#                    download them again
#   KEEP_BACKUPS     $INSTALL_DIR/backups, when the rest of $INSTALL_DIR goes
#   REMOVE_SNAPSHOTS under --keep-workspaces, the snapshots taken of the kept
#                    workspaces — which nothing can restore once the database
#                    that names them is gone
KEEP_CERTS=""
KEEP_IMAGES=""
KEEP_BACKUPS=""
REMOVE_SNAPSHOTS=""
# Derived from the flags above by refresh_modes, never set by hand. Declared
# here so the steps below can read them before the first call.
WIPE_WS=0
WIPE_SNAPS=0
WIPE_CERTS=0
WIPE_IMAGES=0
WIPE_BACKUPS=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 [--dry-run] [--purge] [--keep-workspaces]
                              [--keep-certs] [--keep-images] [--keep-backups]
                              [--remove-snapshots] [--nuke] [--remove-docker]
                              [--no-backup] [--yes]

  On a terminal, a run asks what to remove, part by part: the database and
  config, the workspaces, their snapshots, the TLS certificates, the images,
  the backup archives, whether to take a backup first, install.sh's host
  changes and Docker Engine. Every question defaults to keeping. It then shows
  the plan and asks once to go ahead. The flags below answer those questions
  in advance; with --yes, or with no terminal, nothing is asked and the flags
  are the whole answer.

  --dry-run Print the inventory of what this invocation would destroy and what
            it would keep, then exit without touching anything. Combines with
            every flag below, so --nuke --dry-run reports a --nuke. Needs no
            terminal, so it is safe to pipe from curl.
  --purge   Also remove the workspace containers, their networks and everything
            persistent (workspace volumes, devgrail-data volume, acme certs) and
            the on-disk config in $INSTALL_DIR and $CONFIG_DIR. Without this,
            the workspaces are only stopped and the rest is preserved, so
            install.sh can resume in place.
  --keep-workspaces
            Remove everything of DevGrail's own — the stack, the database, the
            certs, the images and the config in $INSTALL_DIR and $CONFIG_DIR —
            but leave the workspace containers, their volumes, their networks
            and the image they run on. Implies --purge for everything else. A
            later install.sh starts fresh and offers those workspaces back under
            the dashboard's Re-add tab. Refused with --nuke and --remove-docker,
            which destroy the containers this keeps.
  --keep-certs
            With --purge, keep the TLS certificates (the traefik-acme volume).
            A reinstall on the same hostname picks them up instead of ordering
            new ones; Let's Encrypt allows five per hostname a week.
  --keep-images
            Keep the DevGrail images and the third-party images the stack runs
            on, so a reinstall does not download them again.
  --keep-backups
            With --purge, keep the backup archives in $INSTALL_DIR/backups —
            the pre-wipe one included — while the rest of $INSTALL_DIR goes.
  --remove-snapshots
            With --keep-workspaces, remove the workspace snapshots as well.
            They are kept by default, but without the database nothing can
            restore them.
  --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     Ask nothing: remove exactly what the flags say. This is how to run
            unattended; without a terminal, any flag that deletes data needs it.
  --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 ;;
    --keep-workspaces) KEEP_WORKSPACES=1; PURGE=1 ;;
    --nuke)      NUKE=1; PURGE=1 ;;
    --remove-docker) REMOVE_DOCKER=1; NUKE=1; PURGE=1 ;;
    --keep-certs)   KEEP_CERTS=1 ;;
    --keep-images)  KEEP_IMAGES=1 ;;
    --keep-backups) KEEP_BACKUPS=1 ;;
    --remove-snapshots) REMOVE_SNAPSHOTS=1 ;;
    --no-backup) NO_BACKUP=1 ;;
    --dry-run)   DRY_RUN=1 ;;
    --yes|-y)    ASSUME_YES=1 ;;
    -h|--help)   usage; exit 0 ;;
    *) die "unknown argument: $1 (see --help)" ;;
  esac
  shift
done

# Not a preference but an impossibility: --nuke reverts userns-remap, which
# moves Docker off the per-UID data root the kept containers and volumes live
# in, and then reclaims that root — deleting exactly what --keep-workspaces is
# there to preserve. --remove-docker implies --nuke and deletes them outright.
if [ "$KEEP_WORKSPACES" = 1 ] && [ "$NUKE" = 1 ]; then
  die "--keep-workspaces cannot be combined with --nuke or --remove-docker:
  reverting the host changes deletes the containers and volumes it keeps.
  To revert the host as well, the workspaces have to go: use --nuke on its own,
  or move what you want to keep off this host first."
fi
# The same impossibility for the Docker objects the --keep-* flags preserve:
# they live under the remapped data root that --nuke reclaims.
if [ "$NUKE" = 1 ] && { [ "$KEEP_CERTS" = 1 ] || [ "$KEEP_IMAGES" = 1 ]; }; then
  die "--keep-certs and --keep-images cannot be combined with --nuke or --remove-docker:
  reverting the host changes deletes the volume and images they keep."
fi
# With the database kept, a snapshot is still one the dashboard can name and
# restore; removing it underneath would leave rows pointing at nothing.
if [ "$REMOVE_SNAPSHOTS" = 1 ] && [ "$PURGE" = 0 ]; then
  die "--remove-snapshots applies with --keep-workspaces (and --purge removes them anyway).
  On a run that keeps the database, delete snapshots from the dashboard instead."
fi

[ "$(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
}

# --- prompting -------------------------------------------------------------
# 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
}

# ask_default_yes — the same, but Enter means yes. Only for a question whose
# "no" is the one that destroys something: taking the pre-wipe backup.
ask_default_yes() {
  local msg="$1" ans
  printf '%s ' "$msg" > /dev/tty
  read -r ans < /dev/tty || ans=""
  case "$ans" in n|N|no|NO) return 1 ;; *) return 0 ;; esac
}

# have_tty — whether there is a terminal to ask questions on. It *opens*
# /dev/tty rather than testing that the path exists, which is the same test
# install.sh makes and for the same reason: the device node is present in
# plenty of contexts that cannot open it (a detached systemd unit, a container
# started without a tty, some CI runners). Testing existence there sends the
# run down the prompt path, every `read` fails, and a --purge aborts with
# "aborted" instead of taking the documented no-terminal branch below.
have_tty() { { : < /dev/tty; } 2>/dev/null; }


# --- host revert helpers ---------------------------------------------------
# Extracted by test/installer/uninstall_nuke_test.sh and by
# test/installer/uninstall_inventory_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.
#
# These sit above the questions rather than below them because the report that
# precedes those questions asks classify_daemon_json what a --nuke would
# actually revert. Definitions only — nothing here runs until it is called.

# 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. classify_daemon_json sets them as candidates;
# revert_daemon_json clears them again if the write or the restart fails.
REVERTED_USERNS=0
REVERTED_POOLS=0
# Newline-separated "<kind>:<text>" lines explaining whatever
# classify_daemon_json declined to touch, rendered by the caller rather than
# printed where they are found. That is what lets the --dry-run report and the
# real revert share one classification without the report warning about the
# same file twice.
DAEMON_JSON_NOTE=""

# classify_daemon_json — which of install.sh's two keys are still ours to
# remove. Pure and silent: reads $DAEMON_JSON, sets REVERTED_USERNS /
# REVERTED_POOLS and fills DAEMON_JSON_NOTE, and returns 1 when nothing
# install.sh wrote is left in the file. Writes nothing, restarts nothing, so
# the dry-run report can call it too.
#
# Keys count as ours 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.
classify_daemon_json() {
  local current remap

  REVERTED_USERNS=0
  REVERTED_POOLS=0
  DAEMON_JSON_NOTE=""

  if [ ! -s "$DAEMON_JSON" ]; then
    DAEMON_JSON_NOTE="log:No $DAEMON_JSON on this host — nothing to revert."
    return 1
  fi
  if ! jq -e . "$DAEMON_JSON" >/dev/null 2>&1; then
    DAEMON_JSON_NOTE="unparseable:$DAEMON_JSON exists but is not valid JSON — leaving it alone."
    return 1
  fi
  current="$(cat "$DAEMON_JSON")"

  remap="$(printf '%s' "$current" | jq -r '."userns-remap" // empty')"
  case "$remap" in
    default) REVERTED_USERNS=1 ;;
    '')      ;;
    *)       DAEMON_JSON_NOTE="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
    DAEMON_JSON_NOTE="${DAEMON_JSON_NOTE:+$DAEMON_JSON_NOTE
}warn:$DAEMON_JSON has default-address-pools install.sh did not write — leaving them."
  fi

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

# render_daemon_json_note — say out loud what classify_daemon_json left alone.
# Separate from the classification so calling it twice (once for the report,
# once for the revert) does not print the same complaint twice.
render_daemon_json_note() {
  local line
  [ -n "$DAEMON_JSON_NOTE" ] || return 0
  while IFS= read -r line; do
    case "$line" in
      warn:*)        warn "${line#warn:}" ;;
      unparseable:*) warn "${line#unparseable:}"
                     echo "  Remove the userns-remap and default-address-pools keys by hand." >&2 ;;
      log:*)         log "${line#log:}" ;;
    esac
  done <<NOTE
$DAEMON_JSON_NOTE
NOTE
  return 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. The decision of *what* is ours to remove belongs to
# classify_daemon_json; this is the half that writes.
revert_daemon_json() {
  local current merged filter backup stamp rc

  classify_daemon_json
  rc=$?
  render_daemon_json_note
  [ "$rc" = 0 ] || return 1

  current="$(cat "$DAEMON_JSON")"

  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
}

# --- inventory helpers -----------------------------------------------------
# Extracted by test/installer/uninstall_inventory_test.sh between this marker
# and the matching "end inventory 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.
#
# Nothing in here writes, deletes or restarts anything. That is load-bearing
# twice over: the report is printed before the first question is asked, so the
# answer to that question is an informed one, and the same code is the whole of
# what --dry-run does.

# How many names to print per category before summarising the remainder. A host
# with sixty workspaces should not bury the verdict under sixty lines.
INVENTORY_LIST_CAP=20

# Counts the report gathers and the confirmation prompts below quote back, so
# "delete ALL DevGrail data" can name what "all" actually is on this host.
INV_WS_N=0
INV_WS_VOL_N=0
INV_SNAP_N=0
INV_BACKUP_N=0
# Whether the database and certificate volumes exist (0/1): what the per-part
# questions need to know to be about something.
INV_DB_N=0
INV_CERTS_N=0
INV_OTHER_C_N=0
INV_OTHER_V_N=0
# How much of DevGrail's own state (config on disk, data volumes) is still here.
# The prompts read it to decide whether offering --keep-workspaces means
# anything on this host.
INV_OWN_N=0

# Which report this is, for its closing line only: "unattended" (no questions
# will follow — --yes, or no terminal), "survey" (the first report of an
# interactive run, before the questions) or "plan" (the same report again once
# the answers have changed what goes). --dry-run overrides all three.
INVENTORY_STAGE=unattended

# refresh_modes — derive the WIPE_* verdicts from the flags as they stand.
#
# Everything workspace-shaped (the containers, their volumes, their networks,
# the image they run on) tests WIPE_WS rather than PURGE, because
# --keep-workspaces is precisely the split between "wipe DevGrail" and "wipe the
# workspaces with it"; each part a --purge can spare has its own verdict for
# the same reason. An undecided KEEP_* reads as the --purge default, so a run
# nobody answered removes what it always did. It lives in this block, and
# inventory_report calls it first, so the report derives the mode instead of
# trusting a variable somebody set earlier — and so the prompts, which can
# still change the flags, only have to call it again.
refresh_modes() {
  WIPE_WS=0; WIPE_SNAPS=0; WIPE_CERTS=0; WIPE_BACKUPS=0; WIPE_IMAGES=1
  if [ "$PURGE" = 1 ] && [ "$KEEP_WORKSPACES" = 0 ]; then WIPE_WS=1; fi
  WIPE_SNAPS="$WIPE_WS"
  if [ "$PURGE" = 1 ] && [ "$KEEP_WORKSPACES" = 1 ] && [ "$REMOVE_SNAPSHOTS" = 1 ]; then WIPE_SNAPS=1; fi
  if [ "$PURGE" = 1 ] && [ "$KEEP_CERTS" != 1 ]; then WIPE_CERTS=1; fi
  if [ "$PURGE" = 1 ] && [ "$KEEP_BACKUPS" != 1 ]; then WIPE_BACKUPS=1; fi
  if [ "$KEEP_IMAGES" = 1 ]; then WIPE_IMAGES=0; fi
  return 0
}

# inv_count <lines> — how many non-empty lines. `grep -c .` rather than
# `wc -l`: it counts a final line that has no trailing newline and skips blank
# ones, which is exactly the shape `docker ... -q` hands back. wc -l reports 0
# for a one-line answer that ended without a newline.
inv_count() { printf '%s\n' "${1:-}" | grep -c '.' || true; }

# inv_container_name <id> — the name Docker knows a container by, without the
# leading slash, falling back to the id when inspect cannot answer. A report
# that aborts because one container is being removed underneath it is worse
# than one that prints a short id.
inv_container_name() {
  local n
  n="$(docker inspect -f '{{.Name}}' "$1" 2>/dev/null || true)"
  n="${n#/}"
  [ -n "$n" ] || n="$1"
  printf '%s' "$n"
}

# inv_verdict <destroy?> — the word this run has earned for a category.
inv_verdict() { if [ "$1" = 1 ]; then printf 'DESTROY'; else printf 'keep'; fi; }

# inv_head <label> <count> <verdict> [note] — one category summary line.
inv_head() {
  printf '  %-22s %4s  %s%s\n' "$1" "$2" "$3" "${4:+ — $4}"
}

# inv_names <lines> — the names under a category, capped.
inv_names() {
  local names="${1:-}" n shown=0 total
  [ -n "$names" ] || return 0
  total="$(inv_count "$names")"
  while IFS= read -r n; do
    [ -n "$n" ] || continue
    if [ "$shown" -ge "$INVENTORY_LIST_CAP" ]; then
      printf '        ... and %s more\n' "$((total - shown))"
      return 0
    fi
    printf '        %s\n' "$n"
    shown=$((shown + 1))
  done <<NAMES
$names
NAMES
  return 0
}

# inventory_report — print what this invocation would destroy and what it would
# keep, then return. Reads the flags as they stand: the prompts below can still
# escalate --purge, which is why the closing line says so and why those prompts
# quote the INV_* counts this leaves behind.
inventory_report() {
  local ws_c ws_disp ws_note ws_v ws_v_all ws_net_n ws_net_note stack stack_n imgs ws_imgs
  local snap_v snap_imgs snap_note files files_note backups_dir db_vol acme_vol
  local id name state kept=0 anything=0 img_note

  ws_c=""; ws_disp=""; ws_v=""; ws_v_all=""; ws_net_n=0; stack=""; imgs=""
  ws_imgs=""; snap_v=""; snap_imgs=""; files=""; db_vol=""; acme_vol=""
  refresh_modes

  if [ "$HAVE_DOCKER" = 1 ]; then
    ws_c="$(docker ps -a --filter label=devgrail.managed --format '{{.Names}}|{{.State}}' 2>/dev/null || true)"
    while IFS='|' read -r name state; do
      [ -n "$name" ] || continue
      ws_disp="${ws_disp:+$ws_disp
}$(printf '%-28s %s' "$name" "${state:-unknown}")"
    done <<WSC
$ws_c
WSC
    # One listing, split by name. A workspace snapshot's volume carries the same
    # devgrail.managed label — it has to, or nothing here would ever sweep it —
    # but it is a saved copy of a workspace rather than one a workspace is
    # running on, and reporting the two as one number tells an operator their
    # host holds twice the live workspaces it does. The prefix is what makes the
    # split reliable: it is deliberately not VolumePrefix, so no workspace volume
    # can ever be named like one.
    ws_v_all="$(docker volume ls --filter label=devgrail.managed --format '{{.Name}}' 2>/dev/null || true)"
    ws_v="$(printf '%s\n' "$ws_v_all" | { grep -v '^devgrail-snapvol-' || true; } | { grep -v '^$' || true; })"
    snap_v="$(printf '%s\n' "$ws_v_all" | { grep '^devgrail-snapvol-' || true; })"
    # Snapshot images are named after the snapshot's own id, so the exact-name
    # removals this script does elsewhere cannot reach them.
    snap_imgs="$(image_refs 'devgrail-snap-*')"
    ws_net_n="$(inv_count "$(docker network ls -q --filter label=devgrail.managed 2>/dev/null || true)")"

    for id in $(compose_project_containers); do
      stack="${stack:+$stack
}$(inv_container_name "$id")"
    done
    # Two volumes, reported apart because they can be decided apart: the
    # database goes with --purge, the certificates can stay for a reinstall.
    db_vol="$(compose_project_object volume devgrail-data)"
    acme_vol="$(compose_project_object volume traefik-acme)"

    # The workspace image goes with the workspaces and not before: a container
    # that survives this run pins the image it was built from, so `docker rmi`
    # would refuse it anyway — and a resumed install.sh wants to find it loaded.
    # imgs is what the run removes unless --keep-images spares it.
    imgs="$(image_refs devgrail-server)"
    if [ "$WIPE_WS" = 1 ]; then
      imgs="$imgs
$(image_refs devgrail-container)
$(image_refs devgrail-desktop)"
    else
      ws_imgs="$(printf '%s\n' "$(image_refs devgrail-container)" "$(image_refs devgrail-desktop)" | { grep -v '^$' || true; })"
    fi
    if [ "$NUKE" = 1 ]; then
      imgs="$imgs
$(image_refs traefik; image_refs tecnativa/docker-socket-proxy)"
    fi
    imgs="$(printf '%s\n' "$imgs" | { grep -v '^$' || true; })"
  fi

  for name in "$INSTALL_DIR" "$CONFIG_DIR" /usr/local/bin/devgrail \
              /etc/systemd/system/devgrail-backup.timer \
              /etc/systemd/system/devgrail-backup.service \
              /etc/systemd/system/devgrail-upgrade.path \
              /etc/systemd/system/devgrail-upgrade.service; do
    [ -e "$name" ] || continue
    files="${files:+$files
}$name"
  done

  backups_dir="$INSTALL_DIR/backups"
  INV_BACKUP_N=0
  [ -d "$backups_dir" ] && INV_BACKUP_N="$(inv_count "$(ls -1 "$backups_dir" 2>/dev/null || true)")"

  INV_WS_N="$(inv_count "$ws_c")"
  INV_WS_VOL_N="$(inv_count "$ws_v")"
  # An image and a volume per saved workspace, so counting the images counts the
  # snapshots — an ephemeral workspace's snapshot has no volume at all.
  INV_SNAP_N="$(inv_count "$snap_imgs")"
  INV_DB_N="$(inv_count "$db_vol")"
  INV_CERTS_N="$(inv_count "$acme_vol")"
  INV_OWN_N="$(( $(inv_count "$files") + INV_DB_N + INV_CERTS_N ))"

  echo
  echo "============================================================"
  if [ "$DRY_RUN" = 1 ]; then
    echo "  Dry run — nothing on this host has been changed."
  else
    echo "  Uninstalling the DevGrail install at $INSTALL_DIR"
  fi
  echo
  if [ "$REMOVE_DOCKER" = 1 ]; then
    echo "  Mode: full wipe, host changes reverted, Docker Engine removed"
  elif [ "$NUKE" = 1 ]; then
    echo "  Mode: full wipe, and install.sh's host changes reverted"
  elif [ "$KEEP_WORKSPACES" = 1 ]; then
    echo "  Mode: remove DevGrail itself — the workspaces, their volumes and"
    echo "        networks stay on this host"
  elif [ "$PURGE" = 1 ] && [ "$WIPE_CERTS$WIPE_IMAGES$WIPE_BACKUPS" = 111 ]; then
    echo "  Mode: full wipe — workspaces, data, certs and config all go"
  elif [ "$PURGE" = 1 ]; then
    echo "  Mode: remove DevGrail and the workspaces; the items marked"
    echo "        'keep' stay on this host"
  else
    echo "  Mode: preserve workspaces + data (add --purge to wipe them too)"
  fi
  echo

  if [ "$HAVE_DOCKER" = 0 ]; then
    echo "  Docker is not installed on this host, so there are no containers,"
    echo "  volumes, networks or images to account for."
    echo
  else
    if [ "${INV_WS_N:-0}" -gt 0 ]; then
      anything=1
      ws_note=""
      if [ "$WIPE_WS" = 0 ]; then
        kept=1
        if [ "$KEEP_WORKSPACES" = 1 ]; then
          ws_note="stopped, left in place for the Re-add tab"
        else
          ws_note="stopped, left in place"
        fi
      fi
      inv_head "Workspaces" "$INV_WS_N" "$(inv_verdict "$WIPE_WS")" "$ws_note"
      inv_names "$ws_disp"
    fi
    if [ "${ws_net_n:-0}" -gt 0 ]; then
      anything=1
      # Kept whenever the workspaces are, because a preserved container is bound
      # to its network by id: removing one strands the container that runs on it.
      ws_net_note=""
      [ "$WIPE_WS" = 1 ] || { kept=1; ws_net_note="the kept workspaces are bound to them"; }
      inv_head "Workspace networks" "$ws_net_n" "$(inv_verdict "$WIPE_WS")" "$ws_net_note"
    fi
    if [ "${INV_WS_VOL_N:-0}" -gt 0 ]; then
      anything=1
      [ "$WIPE_WS" = 1 ] || kept=1
      inv_head "Workspace volumes" "$INV_WS_VOL_N" "$(inv_verdict "$WIPE_WS")" "your source code lives here"
      inv_names "$ws_v"
    fi
    if [ "${INV_SNAP_N:-0}" -gt 0 ] || [ -n "$snap_v" ]; then
      anything=1
      if [ "$WIPE_SNAPS" = 1 ]; then
        snap_note="saved whole workspaces, restorable over or beside the original"
      elif [ "$PURGE" = 1 ]; then
        kept=1; snap_note="kept, but unrestorable without the database"
      else
        kept=1; snap_note="kept with the workspaces they were taken from"
      fi
      inv_head "Workspace snapshots" "$INV_SNAP_N" "$(inv_verdict "$WIPE_SNAPS")" "$snap_note"
      # Counted, not named, for the reason the networks are: a snapshot is named
      # after its own id, so a screenful of uuids tells the operator nothing the
      # count did not. The dashboard lists them by label.
      [ -n "$snap_v" ] && inv_head "  their volumes" "$(inv_count "$snap_v")" "$(inv_verdict "$WIPE_SNAPS")"
    fi
    stack_n="$(inv_count "$stack")"
    if [ "${stack_n:-0}" -gt 0 ]; then
      anything=1
      inv_head "Stack containers" "$stack_n" "$(inv_verdict 1)"
      inv_names "$stack"
    fi
    if [ -n "$db_vol" ]; then
      anything=1
      [ "$PURGE" = 1 ] || kept=1
      inv_head "Database" "1" "$(inv_verdict "$PURGE")" "accounts, settings, secrets, workspace records"
      inv_names "$db_vol"
    fi
    if [ -n "$acme_vol" ]; then
      anything=1
      if [ "$WIPE_CERTS" = 1 ]; then
        inv_head "TLS certificates" "1" "$(inv_verdict 1)"
      elif [ "$PURGE" = 1 ]; then
        kept=1
        inv_head "TLS certificates" "1" "$(inv_verdict 0)" "a reinstall on the same hostname reuses them"
      else
        kept=1
        inv_head "TLS certificates" "1" "$(inv_verdict 0)"
      fi
      inv_names "$acme_vol"
    fi
    if docker network inspect devgrail >/dev/null 2>&1; then
      anything=1
      inv_head "Edge network" "1" "$(inv_verdict 1)" "devgrail"
    fi
    if [ -n "$imgs" ]; then
      anything=1
      img_note=""
      [ "$WIPE_IMAGES" = 1 ] || { kept=1; img_note="kept, so a reinstall does not download them again"; }
      inv_head "Images" "$(inv_count "$imgs")" "$(inv_verdict "$WIPE_IMAGES")" "$img_note"
      inv_names "$imgs"
    fi
    if [ -n "$ws_imgs" ]; then
      anything=1
      kept=1
      inv_head "Workspace image" "$(inv_count "$ws_imgs")" "$(inv_verdict 0)" "the workspaces above run on it"
      inv_names "$ws_imgs"
    fi
  fi

  if [ -n "$files" ]; then
    anything=1
    [ "$PURGE" = 1 ] || kept=1
    files_note=""
    if [ "$PURGE" = 1 ] && [ "$WIPE_BACKUPS" = 0 ] && [ -d "$backups_dir" ]; then
      files_note="all but $backups_dir"
    fi
    inv_head "Files" "$(inv_count "$files")" "$(inv_verdict "$PURGE")" "$files_note"
    inv_names "$files"
  fi
  if [ "${INV_BACKUP_N:-0}" -gt 0 ]; then
    anything=1
    [ "$WIPE_BACKUPS" = 1 ] || kept=1
    inv_head "Backup archives" "$INV_BACKUP_N" "$(inv_verdict "$WIPE_BACKUPS")" "in $backups_dir"
  fi

  if [ "$anything" = 0 ]; then
    echo "  Nothing of DevGrail's was found on this host."
    echo "  It is either already uninstalled, or was installed somewhere other"
    echo "  than $INSTALL_DIR (set DEVGRAIL_INSTALL_DIR to point at it)."
  fi

  # The host-level sections, each one wider than DevGrail's own namespace.
  if [ "$NUKE" = 1 ]; then
    echo
    echo "  Host changes install.sh made"
    if ! command -v jq >/dev/null 2>&1; then
      echo "        jq is not installed, so $DAEMON_JSON cannot be read or edited"
    elif classify_daemon_json; then
      [ "$REVERTED_USERNS" = 1 ] && echo "        $DAEMON_JSON: userns-remap would be removed"
      [ "$REVERTED_POOLS" = 1 ]  && echo "        $DAEMON_JSON: default-address-pools would be removed"
      # One key can be ours while the other has been changed since. Say which,
      # rather than let the revert be the first mention of it.
      [ -n "$DAEMON_JSON_NOTE" ] && printf '%s\n' "$DAEMON_JSON_NOTE" | sed 's/^[a-z]*:/        /'
      echo "        the Docker daemon would be restarted"
      if [ "$REVERTED_USERNS" = 1 ]; then
        echo "        the dockremap subordinate id ranges would be dropped"
        id dockremap >/dev/null 2>&1 && echo "        the dockremap user would be deleted"
        name="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
        [ -n "$name" ] && echo "        the remapped data root under $name would be reclaimed"
      fi
    else
      # classify_daemon_json declined. Its reason is the useful part; strip the
      # severity tag the renderer would otherwise consume.
      printf '%s\n' "$DAEMON_JSON_NOTE" | sed 's/^[a-z]*:/        /'
    fi
  fi

  if [ "$REMOVE_DOCKER" = 1 ] && [ "$HAVE_DOCKER" = 1 ]; then
    INV_OTHER_C_N="$(inv_count "$(docker ps -aq 2>/dev/null || true)")"
    INV_OTHER_V_N="$(inv_count "$(docker volume ls -q 2>/dev/null || true)")"
    echo
    echo "  Docker Engine itself"
    echo "        EVERY container on this host goes, not only DevGrail's:"
    printf '        %s container(s) and %s volume(s) exist in total\n' \
      "$INV_OTHER_C_N" "$INV_OTHER_V_N"
    echo "        /var/lib/docker, /var/lib/containerd and /etc/docker are removed"
  fi

  # Said whether or not this is a dry run, because it is the whole reason to
  # choose this mode over --purge — and the one thing about it that surprises.
  if [ "$KEEP_WORKSPACES" = 1 ] && [ "${INV_WS_N:-0}" -gt 0 ]; then
    echo
    echo "  The $INV_WS_N workspace(s) above stay, but the database describing them does"
    echo "  not. A fresh install.sh finds the containers again and the dashboard's"
    echo "  Re-add tab offers them back with their files, volume and ports intact."
    echo "  Their names, descriptions, documentation, projects, app subdomains and"
    echo "  API-key grants lived only in the database, so those do not come back and"
    echo "  each workspace gets a new SSH password. Restoring the pre-wipe backup"
    echo "  onto the new install is what brings them back instead."
    if [ "${INV_SNAP_N:-0}" -gt 0 ] && [ "$WIPE_SNAPS" = 0 ]; then
      echo
      echo "  The $INV_SNAP_N workspace snapshot(s) stay on disk too, but nothing will be"
      echo "  able to name them: a snapshot is addressed through the database row that"
      echo "  goes with the wipe, and the Re-add tab only offers containers. Restoring"
      echo "  the pre-wipe backup is what makes them restorable again; short of that,"
      echo "  remove them by hand with: docker rmi \$(docker images -q 'devgrail-snap-*')"
    fi
  fi

  echo
  if [ "$DRY_RUN" = 1 ]; then
    echo "  Re-run without --dry-run to apply this."
  elif [ "$INVENTORY_STAGE" = survey ]; then
    echo "  The verdicts above are where this run starts. The questions below"
    echo "  decide what else goes, and nothing is touched until you confirm."
  elif [ "$INVENTORY_STAGE" = plan ]; then
    echo "  This is the plan. Nothing above is touched until you confirm."
  elif [ "$kept" = 1 ]; then
    echo "  The items marked 'keep' survive this run."
  else
    echo "  Everything above is removed."
  fi
  echo "============================================================"
  echo
  return 0
}
# --- end inventory helpers -------------------------------------------------

# --- report, then confirm --------------------------------------------------
# The report comes first so the questions below are answered by someone who has
# seen the list. It is also the whole of --dry-run, which exits here having read
# the host and written nothing.
#
# Note what this ordering buys: --dry-run returns *before* the no-terminal guard
# at the bottom of this block, so `curl ... | sudo bash -s -- --purge --dry-run`
# reports instead of refusing. That is by construction, not by an exemption —
# keep the exit above the guard.
if [ "$DRY_RUN" = 0 ] && [ "$ASSUME_YES" = 0 ] && have_tty; then
  INVENTORY_STAGE=survey
fi
inventory_report

if [ "$DRY_RUN" = 1 ]; then
  exit 0
fi

# --- confirm + decide what to remove ---------------------------------------
# Extracted by test/installer/uninstall_questions_test.sh between this marker
# and the matching "end confirm" one, and run against scripted answers. Keep
# both comments intact, and keep everything between them to deciding: nothing
# in here may write, delete or restart anything.
#
# An interactive run asks one question per part that can go, each only when the
# flags have not already answered it and only when this host has something for
# it to be about, then shows the plan as it now stands and asks once to go
# ahead. Enter always gives the answer that keeps something. In order, with the
# flag that answers each one in advance:
#
#   database + config  --purge. "No" is the default run: everything stays and
#                      nothing below is asked.
#   workspaces         --purge removes them, --keep-workspaces keeps them.
#   snapshots          --remove-snapshots. Only when the workspaces stay.
#   TLS certificates   --keep-certs
#   images             --keep-images
#   a backup first     --no-backup. The one question whose Enter is "yes".
#   backup archives    --keep-backups
#   host changes       --nuke. Only once nothing Docker holds is being kept:
#                      the revert reclaims the data root it all lives in.
#   Docker Engine      --remove-docker. Only once the host changes go.
#
# The database and the config are one question, not two: config.yaml holds the
# jwt_secret that decrypts the database, so either one without the other is
# unusable. The parts a --purge can spare are asked about only on a run that
# purges; a run that keeps the database keeps all of them.
#
# Unattended runs reach no question at all — --yes takes the flags as given,
# and an unanswered --keep-* means the part goes, as it always has. With no
# terminal, a run that deletes anything of the operator's has to say --yes.
if [ "$ASSUME_YES" = 1 ]; then
  : # honour the flags as given, no prompts
elif have_tty; then
  answers_before="$PURGE $KEEP_WORKSPACES $REMOVE_SNAPSHOTS $KEEP_CERTS $KEEP_IMAGES $NO_BACKUP $KEEP_BACKUPS $NUKE $REMOVE_DOCKER"

  # Everything workspace-shaped, for deciding whether the workspace question is
  # about anything. A snapshot counts: it is a whole saved workspace.
  ws_things=$(( ${INV_WS_N:-0} + ${INV_WS_VOL_N:-0} + ${INV_SNAP_N:-0} ))
  ask_ws=0
  if [ "$PURGE" = 0 ]; then
    if [ "${INV_OWN_N:-0}" -gt 0 ]; then
      echo >&2
      echo "  The database and config: accounts, settings, secrets, API keys and the" >&2
      echo "  records describing each workspace, with the config in $INSTALL_DIR" >&2
      echo "  and $CONFIG_DIR whose key decrypts them. Keep them and a later" >&2
      echo "  install.sh resumes exactly where this left off." >&2
      if ask "Remove the database and config? [y/N]"; then
        PURGE=1; ask_ws=1
      fi
    else
      # An earlier --keep-workspaces already took DevGrail's own state. What is
      # left to decide is the workspaces it kept, and nothing else exists for a
      # "no" to protect, so the workspace question is asked on its own.
      ask_ws=1
    fi
  fi

  # Not asked when the flags settled it either way: --purge means the
  # workspaces go, --keep-workspaces that they stay.
  if [ "$ask_ws" = 1 ] && [ "$ws_things" -gt 0 ]; then
    echo >&2
    echo "  The workspaces: $INV_WS_N container(s), $INV_WS_VOL_N volume(s) and $INV_SNAP_N snapshot(s)." >&2
    echo "  Your source code and everything installed in them lives here." >&2
    ws_too=""
    if [ "$PURGE" = 1 ]; then
      ws_too=" too"
      echo "  Keep them and a fresh install.sh offers them back under the" >&2
      echo "  dashboard's Re-add tab, with their files, volume and ports intact." >&2
    fi
    if ask "Remove the workspaces${ws_too}? [y/N]"; then
      PURGE=1; KEEP_WORKSPACES=0
    elif [ "$PURGE" = 1 ]; then
      KEEP_WORKSPACES=1
    fi
  fi

  # The parts a purge can spare. Every one is decided before the host question,
  # which is only offered once none of them is being kept on Docker's side.
  if [ "$PURGE" = 1 ]; then
    if [ "$KEEP_WORKSPACES" = 1 ] && [ -z "$REMOVE_SNAPSHOTS" ] && [ "${INV_SNAP_N:-0}" -gt 0 ]; then
      echo >&2
      echo "  The $INV_SNAP_N workspace snapshot(s). They can stay with the workspaces," >&2
      echo "  but the database that names them goes, so a fresh install cannot" >&2
      echo "  restore them unless the pre-wipe backup is restored onto it first." >&2
      if ask "Remove the snapshots? [y/N]"; then REMOVE_SNAPSHOTS=1; else REMOVE_SNAPSHOTS=0; fi
    fi

    if [ "$NUKE" = 0 ] && [ -z "$KEEP_CERTS" ] && [ "${INV_CERTS_N:-0}" -gt 0 ]; then
      echo >&2
      echo "  The TLS certificates. Kept, a reinstall on the same hostname reuses" >&2
      echo "  them; removed, it orders new ones, and Let's Encrypt issues at most" >&2
      echo "  five per hostname a week." >&2
      if ask "Remove the TLS certificates? [y/N]"; then KEEP_CERTS=0; else KEEP_CERTS=1; fi
    fi

    # Counted here rather than read from the report: the workspace answer above
    # decides whether the workspace image is among them.
    if [ "$NUKE" = 0 ] && [ -z "$KEEP_IMAGES" ] && [ "$HAVE_DOCKER" = 1 ]; then
      refresh_modes
      imgs_n="$(inv_count "$(image_refs devgrail-server)")"
      if [ "$WIPE_WS" = 1 ]; then
        imgs_n=$(( imgs_n + $(inv_count "$(image_refs devgrail-container)") + $(inv_count "$(image_refs devgrail-desktop)") ))
      fi
      if [ "$imgs_n" -gt 0 ]; then
        echo >&2
        echo "  The $imgs_n DevGrail image tag(s) this run would remove. Kept, a" >&2
        echo "  reinstall of the same release does not download them again." >&2
        if ask "Remove the images? [y/N]"; then KEEP_IMAGES=0; else KEEP_IMAGES=1; fi
      fi
    fi

    # Asked about only where there is a database to save. Enter takes it: of
    # all the questions, this is the one whose "no" destroys something.
    will_backup=0
    if [ "$NO_BACKUP" = 0 ] && [ "${INV_DB_N:-0}" -gt 0 ]; then
      echo >&2
      if [ -x "$DEVGRAIL_CLI" ]; then
        echo "  A backup of the database and config.yaml, written to" >&2
        echo "  $INSTALL_DIR/backups before anything is removed. It is the only way" >&2
        echo "  back from removing the database." >&2
        if ask_default_yes "Take a backup first? [Y/n]"; then will_backup=1; else NO_BACKUP=1; fi
      else
        warn "There is no $DEVGRAIL_CLI on this host, so no backup can be taken first."
        echo "  This install predates the maintenance CLI. Re-run install.sh to get it" >&2
        echo "  and take one, or go on and remove the database unrecoverably." >&2
        if ask "Remove the database without a backup? [y/N]"; then NO_BACKUP=1; else die "aborted"; fi
      fi
    fi

    backups_n=$(( ${INV_BACKUP_N:-0} + will_backup ))
    if [ -z "$KEEP_BACKUPS" ] && [ "$backups_n" -gt 0 ]; then
      echo >&2
      if [ "$will_backup" = 1 ]; then
        echo "  The backup archives in $INSTALL_DIR/backups: ${INV_BACKUP_N:-0} already there," >&2
        echo "  and the one about to be taken." >&2
      else
        echo "  The ${INV_BACKUP_N:-0} backup archive(s) in $INSTALL_DIR/backups." >&2
      fi
      echo "  Kept, they stay on this host when the rest of $INSTALL_DIR goes, and" >&2
      echo "  'devgrail restore' on a new install can bring the data back from them." >&2
      if ask "Remove the backup archives? [y/N]"; then KEEP_BACKUPS=0; else KEEP_BACKUPS=1; fi
    fi
  fi

  # Offered separately, because it is a different blast radius: everything above
  # is DevGrail's own, everything here is the host's. Reverting userns-remap
  # reclaims the data root everything Docker holds lives in, so it is not
  # offered while any of that is being kept — and says so, rather than leave
  # the operator wondering where the question went.
  if [ "$PURGE" = 1 ] && [ "$NUKE" = 0 ]; then
    host_blocked=""
    [ "$KEEP_WORKSPACES" = 1 ] && host_blocked="the workspaces"
    [ "$KEEP_CERTS" = 1 ] && host_blocked="${host_blocked:+$host_blocked, }the TLS certificates"
    [ "$KEEP_IMAGES" = 1 ] && host_blocked="${host_blocked:+$host_blocked, }the images"
    echo >&2
    if [ -n "$host_blocked" ]; then
      echo "  Not offering to revert install.sh's changes to the host: that reclaims" >&2
      echo "  Docker's data root, which would delete $host_blocked." >&2
    else
      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
  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
    # Counted here rather than read from the report: --remove-docker was still
    # off when that ran, so this is the first point at which the number means
    # anything. It is the whole host, deliberately unfiltered by label.
    if [ "$HAVE_DOCKER" = 1 ]; then
      INV_OTHER_C_N="$(inv_count "$(docker ps -aq 2>/dev/null || true)")"
      INV_OTHER_V_N="$(inv_count "$(docker volume ls -q 2>/dev/null || true)")"
      echo "  This host has $INV_OTHER_C_N container(s) and $INV_OTHER_V_N volume(s) in total." >&2
    fi
    echo >&2
    if ask "Also uninstall Docker Engine itself? [y/N]"; then REMOVE_DOCKER=1; fi
  fi

  # The survey above was for the flags as given. If an answer changed what goes,
  # print the report again, so the go-ahead is given to the plan itself rather
  # than to a set of answers the operator has to add up in their head.
  if [ "$PURGE $KEEP_WORKSPACES $REMOVE_SNAPSHOTS $KEEP_CERTS $KEEP_IMAGES $NO_BACKUP $KEEP_BACKUPS $NUKE $REMOVE_DOCKER" != "$answers_before" ]; then
    INVENTORY_STAGE=plan
    inventory_report
  fi
  refresh_modes

  # One go-ahead for the whole run, naming everything it cannot give back.
  gone=""
  if [ "$PURGE" = 1 ]; then
    gone="the database, and the config in $INSTALL_DIR and $CONFIG_DIR"
    if [ "$WIPE_WS" = 1 ] && [ "$ws_things" -gt 0 ]; then
      gone="$gone
$INV_WS_N workspace container(s) and $INV_WS_VOL_N workspace volume(s)"
    fi
    if [ "$WIPE_SNAPS" = 1 ] && [ "${INV_SNAP_N:-0}" -gt 0 ]; then
      gone="$gone
$INV_SNAP_N workspace snapshot(s)"
    fi
    if [ "$WIPE_CERTS" = 1 ] && [ "${INV_CERTS_N:-0}" -gt 0 ]; then
      gone="$gone
the TLS certificates"
    fi
    if [ "$WIPE_BACKUPS" = 1 ] && [ "$(( ${INV_BACKUP_N:-0} + ${will_backup:-0} ))" -gt 0 ]; then
      gone="$gone
the backup archives in $INSTALL_DIR/backups"
    fi
  fi
  [ "$NUKE" = 1 ] && gone="$gone
install.sh's changes to the host, and Docker's remapped data root"
  [ "$REMOVE_DOCKER" = 1 ] && gone="$gone
Docker Engine, and EVERY container, image and volume on this host"

  if [ -n "$gone" ]; then
    warn "This permanently deletes:"
    printf '%s\n' "$gone" | sed 's/^/    - /' >&2
    if [ "$PURGE" = 1 ] && [ "$NO_BACKUP" = 1 ]; then
      warn "No backup is taken first."
    fi
    ask "Go ahead? [y/N]" || die "aborted"
  else
    ask "Stop DevGrail, keeping the workspaces, data and config? [y/N]" || die "aborted"
  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
# --- end confirm -----------------------------------------------------------

refresh_modes

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 [ "$KEEP_WORKSPACES" = 1 ]; then
  log "Uninstalling DevGrail — removing its own state; the workspaces, their volumes and networks are kept."
elif [ "$PURGE" = 1 ] && [ "$WIPE_CERTS$WIPE_IMAGES$WIPE_BACKUPS" = 111 ]; then
  log "Uninstalling DevGrail — FULL WIPE (data, certs, and config will be removed)."
elif [ "$PURGE" = 1 ]; then
  log "Uninstalling DevGrail — removing the database, config and workspaces; keeping what the plan marked 'keep'."
else
  log "Uninstalling DevGrail (workspaces stopped; they, the data volumes and the config are preserved; use --purge to wipe)."
fi

[ "$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 "$DEVGRAIL_CLI" ]; then
    log "Taking a final backup before wiping..."
    "$DEVGRAIL_CLI" 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."
    if [ "$WIPE_BACKUPS" = 1 ]; then
      log "Backup kept in $INSTALL_DIR/backups — it survives this uninstall only if you copy it off this host first."
    else
      log "Backup kept in $INSTALL_DIR/backups, which this uninstall leaves in place."
    fi
  else
    warn "no $DEVGRAIL_CLI 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 ---------------------------------------
# The dashboard-upgrade watcher goes first, in every mode: with the stack on its
# way down there is no dashboard to ask for an upgrade, and a request left in
# the directory must not start the installer halfway through this uninstall.
# The units themselves are removed with the rest of the config under --purge;
# the next install.sh run switches them back on.
if command -v systemctl >/dev/null 2>&1; then
  systemctl disable --now devgrail-upgrade.path >/dev/null 2>&1 || true
fi
# Infra containers (traefik, devgrail-server, the two docker socket proxies, and
# the exited devgrail-data-init one-shot), the managed devgrail-control,
# devgrail-docker and devgrail-traefik-docker networks, and — with --purge — the
# named volumes (devgrail-data, traefik-acme). `down -v` takes both, so a purge
# that keeps the certificates goes down without it and removes the database
# volume by name below.
if [ -f "$COMPOSE_FILE" ] && docker compose version >/dev/null 2>&1; then
  log "Stopping the DevGrail Compose stack..."
  if [ "$PURGE" = 1 ] && [ "$WIPE_CERTS" = 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 devgrail-traefik-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
    if [ "$vol" = traefik-acme ] && [ "$WIPE_CERTS" = 0 ]; then
      [ -n "$(compose_project_object volume "$vol")" ] &&
        log "Kept the TLS certificates — the next install.sh on this hostname reuses them."
      continue
    fi
    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) --
# Only --purge removes the containers. A workspace's filesystem is not all in its
# volume — installed packages, shell history, whatever the agent set up outside
# /home live in the container's writable layer — so a default run stops them and
# leaves them in place, and a re-run of install.sh finds the same workspaces
# rather than empty replacements. Reconcile on server startup reads their real
# state back out of Docker, so the dashboard shows them stopped, not missing.
ws_containers="$(docker ps -aq --filter label=devgrail.managed 2>/dev/null || true)"
if [ "$WIPE_WS" = 1 ]; then
  log "Removing workspace containers..."
  # shellcheck disable=SC2086
  [ -n "$ws_containers" ] && docker rm -f $ws_containers >/dev/null 2>&1 || true
elif [ -n "$ws_containers" ]; then
  if [ "$KEEP_WORKSPACES" = 1 ]; then
    log "Stopping $(inv_count "$ws_containers") workspace container(s), keeping them for the Re-add tab..."
  else
    log "Stopping $(inv_count "$ws_containers") workspace container(s), keeping them (use --purge to remove)..."
  fi
  # One call for the whole labeled set: `docker stop` on an already-stopped
  # container is a no-op, so the exited ones cost nothing here.
  # shellcheck disable=SC2086
  docker stop $ws_containers >/dev/null 2>&1 ||
    warn "not every workspace container stopped cleanly — they were left in place regardless"
fi

# Removed only by --purge, which is to say only once the containers that live on
# them are gone too. A preserved container is pinned to its network by *id*:
# Docker records the network id in the container's endpoint and resolves the
# endpoint from that id on start, not from the name. Remove the network here and
# a later install.sh recreates one with the same name and a new id, at which
# point every preserved workspace fails to start with
#   failed to set up container networking: network <old id> not found
# and the only way back is the re-add flow in the dashboard. Recreating the name
# is not enough, so "the server recreates it on next start" was never true.
#
# The second old reason does not hold either: install.sh's docker_has_state
# counts containers, volumes and images and never looks at networks — and in
# preserve mode the containers it does count are still here regardless. So a
# preserve run gained nothing by removing these and broke what it set out to keep.
ws_networks="$(docker network ls -q --filter label=devgrail.managed 2>/dev/null || true)"
if [ "$WIPE_WS" = 1 ]; then
  if [ -n "$ws_networks" ]; then
    log "Removing workspace networks..."
    # shellcheck disable=SC2086
    docker network rm $ws_networks >/dev/null 2>&1 || true
  fi
else
  [ -n "$ws_networks" ] && log "Preserved $(inv_count "$ws_networks") workspace network(s) — the kept containers are bound to them."
fi

# A snapshot's volume carries the same label, and is told apart by its name
# prefix (see inventory_report). It goes with the workspaces, or on its own when
# the workspaces stay and their snapshots were chosen to go.
ws_volumes="$(docker volume ls -q --filter label=devgrail.managed 2>/dev/null || true)"
snap_volumes="$(printf '%s\n' "$ws_volumes" | { grep '^devgrail-snapvol-' || true; })"
if [ "$WIPE_WS" = 1 ]; then
  log "Removing workspace volumes..."
  # shellcheck disable=SC2086
  [ -n "$ws_volumes" ] && docker volume rm $ws_volumes >/dev/null 2>&1 || true
else
  if [ "$WIPE_SNAPS" = 1 ] && [ -n "$snap_volumes" ]; then
    log "Removing workspace snapshot volumes..."
    # shellcheck disable=SC2086
    docker volume rm $snap_volumes >/dev/null 2>&1 || true
    ws_volumes="$(printf '%s\n' "$ws_volumes" | { grep -v '^devgrail-snapvol-' || true; })"
  fi
  [ -n "$ws_volumes" ] && log "Preserved $(inv_count "$ws_volumes") 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.
if [ "$WIPE_IMAGES" = 0 ]; then
  log "Kept the DevGrail images — a reinstall of the same release will not download them again."
else
  log "Removing DevGrail images..."
  remove_image_refs devgrail-server
  # The workspace image only goes once the workspaces built on it have. In a
  # default run `docker rmi` would refuse it anyway ("image is being used by
  # stopped container"), and keeping it means a resumed install.sh already has
  # the bytes instead of downloading ~750 MiB again.
  if [ "$WIPE_WS" = 1 ]; then
    remove_image_refs devgrail-container
    remove_image_refs devgrail-desktop
  elif [ -n "$(image_refs devgrail-container)$(image_refs devgrail-desktop)" ]; then
    log "Kept the workspace images — preserved workspaces may run on them."
  fi
fi
# Workspace snapshots: one image per saved workspace, named after the snapshot's
# own id rather than after a repository, so every removal above misses them by
# construction. Their volumes went with the workspace volumes in section 2,
# which they share the devgrail.managed label with; without this the images
# would be the one thing a --purge left behind, and unnameable at that, since
# the database that knew what each one was is gone.
snap_imgs="$(image_refs 'devgrail-snap-*')"
if [ -n "$snap_imgs" ]; then
  if [ "$WIPE_SNAPS" = 1 ]; then
    log "Removing $(inv_count "$snap_imgs") workspace snapshot image(s)..."
    remove_image_refs 'devgrail-snap-*'
  else
    log "Kept $(inv_count "$snap_imgs") workspace snapshot image(s) — the preserved workspaces can still be restored from them."
  fi
fi
# 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 ] && [ "$WIPE_IMAGES" = 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 [ "$WIPE_BACKUPS" = 0 ] && [ -d "$INSTALL_DIR/backups" ]; then
    # Everything in $INSTALL_DIR but the archives. Nothing a later install.sh
    # reads is left behind: it decides "upgrade" from $DEPLOY_DIR/.env, which
    # goes with the rest.
    log "Keeping $INSTALL_DIR/backups."
    find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 ! -name backups -exec rm -rf {} +
    rm -rf "$CONFIG_DIR"
  else
    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"
  fi
  rm -f "$DEVGRAIL_CLI"
  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
    rm -f /etc/systemd/system/devgrail-upgrade.path /etc/systemd/system/devgrail-upgrade.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 [ "$KEEP_WORKSPACES" = 1 ]; then
  cat <<EOF

  DevGrail's own state is gone — the stack, the database and
  the config in $INSTALL_DIR and $CONFIG_DIR. The workspace
  containers were stopped and left on this host, with their
  volumes, their networks and the image they run on.

  Re-run install.sh to start fresh, then open the dashboard's
  Re-add tab: it lists those containers and re-adds them with
  their files, volume and ports intact. To get their names,
  descriptions, projects, subdomains and key grants back as
  well, restore the pre-wipe backup onto the new install
  instead.
EOF
elif [ "$PURGE" = 1 ]; then
  cat <<EOF

  The database, the config and the workspaces are gone.
EOF
else
  cat <<EOF

  Nothing of yours was deleted: the workspace containers were
  stopped and left in place, along with the image they run on,
  their volumes, their networks, the devgrail-data + traefik-acme
  volumes and the config in $INSTALL_DIR / $CONFIG_DIR. Re-run
  install.sh to resume with the same workspaces, or re-run this
  with --purge to delete everything.
EOF
fi
if [ "$PURGE" = 1 ] && [ "$NUKE" = 0 ]; then
  kept_list=""
  if [ "$KEEP_WORKSPACES" = 1 ] && [ "$WIPE_SNAPS" = 0 ] && [ "${INV_SNAP_N:-0}" -gt 0 ]; then
    kept_list="$kept_list
    - $INV_SNAP_N workspace snapshot(s), restorable once the backup is"
  fi
  if [ "$WIPE_CERTS" = 0 ] && [ "${INV_CERTS_N:-0}" -gt 0 ]; then
    kept_list="$kept_list
    - the TLS certificates, which the next install.sh reuses"
  fi
  [ "$WIPE_IMAGES" = 0 ] && kept_list="$kept_list
    - the DevGrail images, so a reinstall skips the download"
  if [ "$WIPE_BACKUPS" = 0 ] && [ -d "$INSTALL_DIR/backups" ]; then
    kept_list="$kept_list
    - the backup archives in $INSTALL_DIR/backups"
  elif [ "$NO_BACKUP" = 0 ]; then
    kept_list="$kept_list
    (the pre-wipe backup went with $INSTALL_DIR — it survives
    only if you copied it off this host)"
  fi
  if [ -n "$kept_list" ]; then
    printf '\n  Also kept on this host, as you chose:%s\n' "$kept_list"
  fi
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
