#!/usr/bin/env bash
# Purpose: take a consistent, restorable backup of the chat front-end's data - accounts,
#          chat history, settings and uploaded files - from either the Docker volume used
#          by compose.yaml or a native install's data directory, check that the archive
#          reads back and that the SQLite database inside it passes an integrity check,
#          and record the result in the lab notebook
# Platform: all (Linux, macOS and WSL2; the volume path needs Docker, the directory path
#           does not)
# Minimum memory: 8 GB
# Assumes: for a volume, run from the directory holding compose.yaml and .env (the
#          front-end is stopped with `docker compose stop` for the duration, unless
#          --no-stop is given); tar on PATH; python3 on PATH for the database check (it
#          is skipped, and says so, without it); enough free space in the destination for
#          a compressed copy of the data. The front-end's database is SQLite in WAL mode,
#          so committed data can sit in webui.db-wal until the process closes the file:
#          a copy taken while it runs is a copy you have not shown to be complete.
#
# Usage: bash backup-webui.sh [--dest DIR] [--volume NAME] [--service NAME]
#                             [--data-dir PATH] [--no-stop] [--labbook FILE]
#   e.g. bash backup-webui.sh --dest ~/backups --labbook ~/home-chat/labbook.md
#        bash backup-webui.sh --data-dir ~/home-chat/webui-data --dest ~/backups

set -euo pipefail

DEST="${HOME}/backups"
VOLUME="home-chat_webui-data"
DATA_DIR=""
SERVICE="webui"
STOP=1
LABBOOK=""
ALPINE_IMAGE="alpine:3.24"

usage() { sed -n '2,21p' "$0"; }

while [ $# -gt 0 ]; do
  case "$1" in
    --dest)      DEST="${2:?--dest needs a directory}"; shift 2 ;;
    --volume)    VOLUME="${2:?--volume needs a volume name}"; shift 2 ;;
    --data-dir)  DATA_DIR="${2:?--data-dir needs a path}"; shift 2 ;;
    --service)   SERVICE="${2:?--service needs a compose service name}"; shift 2 ;;
    --no-stop)   STOP=0; shift ;;
    --labbook)   LABBOOK="${2:?--labbook needs a file}"; shift 2 ;;
    -h|--help)   usage; exit 0 ;;
    *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
  esac
done

command -v tar >/dev/null 2>&1 || { echo "tar is not on PATH." >&2; exit 1; }

mkdir -p "$DEST"
DEST="$(cd "$DEST" && pwd)"            # docker needs an absolute path for the bind mount
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
ARCHIVE="${DEST}/open-webui-${STAMP}.tar.gz"
WORK="$(mktemp -d)"
STOPPED=0

cleanup() {
  rm -rf "$WORK"
  if [ "$STOPPED" -eq 1 ]; then
    echo "==> Starting ${SERVICE} again"
    docker compose start "$SERVICE" >/dev/null
  fi
}
trap cleanup EXIT

if [ -n "$DATA_DIR" ]; then
  # Native install: an ordinary directory on this machine.
  [ -d "$DATA_DIR" ] || { echo "No such directory: ${DATA_DIR}" >&2; exit 1; }
  [ -f "${DATA_DIR}/webui.db" ] || { echo "No webui.db in ${DATA_DIR}; is this the front-end's DATA_DIR?" >&2; exit 1; }
  if [ "$STOP" -eq 1 ]; then
    echo "==> Stop the front-end now (Ctrl-C in its terminal), then press return."
    read -r _
  fi
  echo "==> Archiving ${DATA_DIR}"
  # -C DIR . stores entries as ./webui.db and so on, the same layout as the volume path.
  tar -czf "$ARCHIVE" -C "$DATA_DIR" .
  SOURCE="directory ${DATA_DIR}"
else
  command -v docker >/dev/null 2>&1 || { echo "docker is not on PATH." >&2; exit 1; }
  docker volume inspect "$VOLUME" >/dev/null 2>&1 || {
    echo "No such Docker volume: ${VOLUME}" >&2
    echo "List them with: docker volume ls" >&2
    exit 1
  }
  if [ "$STOP" -eq 1 ]; then
    [ -f compose.yaml ] || {
      echo "No compose.yaml in $(pwd). Run this from the stack's directory (cd ~/home-chat)," >&2
      echo "or pass --no-stop and accept an unverified copy of a live database." >&2
      exit 1
    }
    echo "==> Stopping ${SERVICE} so the database is closed while it is copied"
    docker compose stop "$SERVICE" >/dev/null
    STOPPED=1
  fi
  echo "==> Archiving volume ${VOLUME}"
  # A throwaway container mounts the volume read-only, writes the archive into the
  # destination, and hands the file to the user who ran this script (the container
  # runs as root, and a root-owned archive is one you cannot delete without sudo).
  docker run --rm \
    -v "${VOLUME}:/source:ro" \
    -v "${DEST}:/backup" \
    "$ALPINE_IMAGE" \
    sh -c "tar -czf /backup/$(basename "$ARCHIVE") -C /source . && chown $(id -u):$(id -g) /backup/$(basename "$ARCHIVE")"
  SOURCE="docker volume ${VOLUME}"
  if [ "$STOPPED" -eq 1 ]; then
    echo "==> Starting ${SERVICE} again"
    docker compose start "$SERVICE" >/dev/null
    STOPPED=0
  fi
fi

echo "==> Verifying the archive can be read back"
tar -tzf "$ARCHIVE" > "${WORK}/listing"
ENTRIES="$(wc -l < "${WORK}/listing" | tr -d ' ')"
BYTES="$(wc -c < "$ARCHIVE" | tr -d ' ')"
if command -v sha256sum >/dev/null 2>&1; then
  SHA256="$(sha256sum "$ARCHIVE" | cut -d ' ' -f 1)"
else
  SHA256="$(shasum -a 256 "$ARCHIVE" | cut -d ' ' -f 1)"
fi
# GNU tar, bsdtar and BusyBox tar all store "-C DIR ." entries as ./name; accept a bare
# name as well so an archive made some other way is still checked rather than rejected.
DB_MEMBER="$(grep -E '^(\./)?webui\.db$' "${WORK}/listing" | head -n 1 || true)"
[ -n "$DB_MEMBER" ] || {
  echo "The archive has no webui.db at its top level; it is not a front-end backup." >&2
  exit 1
}

DB_CHECK="skipped"
USERS="null"
CHATS="null"
if command -v python3 >/dev/null 2>&1; then
  echo "==> Checking the database inside the archive"
  MEMBERS=("$DB_MEMBER")
  for extra in "${DB_MEMBER}-wal" "${DB_MEMBER}-shm"; do
    if grep -qxF "$extra" "${WORK}/listing"; then MEMBERS+=("$extra"); fi
  done
  tar -xzf "$ARCHIVE" -C "$WORK" "${MEMBERS[@]}"
  RESULT="$(python3 - "${WORK}/webui.db" <<'PY'
import sqlite3, sys
con = sqlite3.connect(sys.argv[1])
ok = con.execute("PRAGMA integrity_check").fetchone()[0]
users = con.execute('SELECT COUNT(*) FROM "user"').fetchone()[0]
chats = con.execute("SELECT COUNT(*) FROM chat").fetchone()[0]
print(ok, users, chats)
PY
)"
  read -r DB_CHECK USERS CHATS <<< "$RESULT"
  if [ "$DB_CHECK" != "ok" ]; then
    echo "SQLite integrity_check did not return ok: ${RESULT}" >&2
    exit 1
  fi
else
  echo "==> python3 not found; the database check was skipped"
fi

echo "    ${ARCHIVE}"
echo "    ${ENTRIES} entries, ${BYTES} bytes, sha256 ${SHA256}"
echo "    integrity_check=${DB_CHECK} users=${USERS} chats=${CHATS}"

if [ -n "$LABBOOK" ]; then
  printf '{"lab":"part-07/lab-private-chat-service-on-your-lan","step":"backup","archive":"%s","source":"%s","entries":%s,"bytes":%s,"sha256":"%s","integrity_check":"%s","users":%s,"chats":%s,"taken":"%s"}\n' \
    "$(basename "$ARCHIVE")" "$SOURCE" "$ENTRIES" "$BYTES" "$SHA256" "$DB_CHECK" "$USERS" "$CHATS" "$STAMP" >> "$LABBOOK"
  echo "    recorded in ${LABBOOK}"
fi

echo "==> Done. Restore it into a scratch volume (task 10 on the lab page) before you rely on it."
