#!/usr/bin/env bash
set -Eeuo pipefail

usage() {
  echo "Usage: sudo $0 <major_version> <cluster> <new_data_directory>"
  echo "Example: sudo $0 16 main /data/postgresql/16/main"
}

fail() {
  echo "ERROR: $*" >&2
  exit 1
}

log() {
  echo "[$(date --iso-8601=seconds)] $*"
}

[[ $EUID -eq 0 ]] || fail "Run this script as root."
[[ $# -eq 3 ]] || { usage; exit 2; }

PG_VERSION="$1"
PG_CLUSTER="$2"
REQUESTED_DATA="$3"
SERVICE="postgresql@${PG_VERSION}-${PG_CLUSTER}.service"
CONFIG="/etc/postgresql/${PG_VERSION}/${PG_CLUSTER}/postgresql.conf"

[[ "$PG_VERSION" =~ ^[0-9]+$ ]] || fail "Invalid PostgreSQL major version."
[[ "$PG_CLUSTER" =~ ^[A-Za-z0-9_-]+$ ]] || fail "Invalid cluster name."
[[ "$REQUESTED_DATA" =~ ^/[A-Za-z0-9._/-]+$ ]] || \
  fail "The new path must be absolute and use only letters, numbers, dot, underscore, slash, or dash."
[[ "$REQUESTED_DATA" != "/" ]] || fail "Refusing to use / as PGDATA."

for command_name in pg_lsclusters psql realpath rsync runuser systemctl; do
  command -v "$command_name" >/dev/null || fail "Missing command: $command_name"
done

[[ -f "$CONFIG" ]] || fail "Configuration not found: $CONFIG"
systemctl is-active --quiet "$SERVICE" || fail "$SERVICE is not active."

PORT="$(pg_lsclusters --no-header | awk -v version="$PG_VERSION" -v cluster="$PG_CLUSTER" \
  '$1 == version && $2 == cluster { print $3; exit }')"
[[ "$PORT" =~ ^[0-9]+$ ]] || fail "Could not determine the cluster port."

PSQL=(runuser -u postgres -- psql -XAt --no-password --port="$PORT" --dbname=template1)
OLD_DATA="$("${PSQL[@]}" --command='SHOW data_directory')"
SETTING_SOURCE="$("${PSQL[@]}" --command="SELECT sourcefile FROM pg_settings WHERE name = 'data_directory'")"
[[ -n "$OLD_DATA" ]] || fail "Could not read the current data_directory."
[[ -n "$SETTING_SOURCE" ]] || fail "data_directory is not sourced from a configuration file."

OLD_DATA="$(realpath -e "$OLD_DATA")"
NEW_DATA="$(realpath -m "$REQUESTED_DATA")"
CONFIG_REAL="$(realpath -e "$CONFIG")"
SETTING_SOURCE_REAL="$(realpath -e "$SETTING_SOURCE")"

[[ "$SETTING_SOURCE_REAL" == "$CONFIG_REAL" ]] || \
  fail "data_directory comes from $SETTING_SOURCE_REAL, not $CONFIG_REAL. Update this runbook for that layout."
[[ "$OLD_DATA" != "$NEW_DATA" ]] || fail "The source and destination are identical."
[[ -f "$OLD_DATA/PG_VERSION" ]] || fail "PG_VERSION is missing from $OLD_DATA."
[[ "$(tr -d '[:space:]' < "$OLD_DATA/PG_VERSION")" == "$PG_VERSION" ]] || \
  fail "The source data directory belongs to another PostgreSQL major version."

case "$NEW_DATA/" in
  "$OLD_DATA/"*) fail "The destination cannot be inside the current data directory." ;;
esac

if [[ -d "$NEW_DATA" ]] && [[ -n "$(find "$NEW_DATA" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
  fail "The destination exists and is not empty: $NEW_DATA"
fi

install -d -o postgres -g postgres -m 700 "$NEW_DATA"

REQUIRED_KB="$(du -sk "$OLD_DATA" | awk '{ print $1 }')"
AVAILABLE_KB="$(df -Pk "$NEW_DATA" | awk 'NR == 2 { print $4 }')"
MINIMUM_KB=$(( REQUIRED_KB + REQUIRED_KB / 10 ))
(( AVAILABLE_KB >= MINIMUM_KB )) || \
  fail "Insufficient space: need at least ${MINIMUM_KB} KiB, have ${AVAILABLE_KB} KiB."

OLD_DEVICE="$(df -P "$OLD_DATA" | awk 'NR == 2 { print $1 }')"
NEW_DEVICE="$(df -P "$NEW_DATA" | awk 'NR == 2 { print $1 }')"
if [[ "$OLD_DEVICE" == "$NEW_DEVICE" && "${ALLOW_SAME_FILESYSTEM:-0}" != "1" ]]; then
  fail "Source and destination are on the same filesystem ($OLD_DEVICE). Is the new disk mounted? Set ALLOW_SAME_FILESYSTEM=1 only if this is intentional."
fi

log "Source: $OLD_DATA"
log "Destination: $NEW_DATA"
log "Starting online staging copy. This pass is not a backup."
ONLINE_RSYNC_STATUS=0
rsync -aH --numeric-ids --delete "$OLD_DATA/" "$NEW_DATA/" || ONLINE_RSYNC_STATUS=$?
if (( ONLINE_RSYNC_STATUS != 0 && ONLINE_RSYNC_STATUS != 24 )); then
  fail "The online rsync failed with status $ONLINE_RSYNC_STATUS."
fi
if (( ONLINE_RSYNC_STATUS == 24 )); then
  log "Files changed during the online copy (rsync status 24); the offline pass will reconcile them."
fi

TIMESTAMP="$(date +%Y%m%d%H%M%S)"
CONFIG_BACKUP="${CONFIG}.before-pgdata-move.${TIMESTAMP}"
cp -a -- "$CONFIG" "$CONFIG_BACKUP"

ROLLBACK_REQUIRED=1
rollback() {
  local exit_code=$?
  trap - EXIT
  if (( ROLLBACK_REQUIRED )); then
    echo "Cutover failed; restoring $CONFIG_BACKUP" >&2
    systemctl stop "$SERVICE" || true
    cp -a -- "$CONFIG_BACKUP" "$CONFIG"
    systemctl start "$SERVICE" || \
      echo "Automatic restart on the old directory failed; inspect journalctl -u $SERVICE." >&2
  fi
  exit "$exit_code"
}
trap rollback EXIT

log "Stopping $SERVICE for the final synchronization."
systemctl stop "$SERVICE"
systemctl is-active --quiet "$SERVICE" && fail "$SERVICE did not stop."

log "Running final offline synchronization."
rsync -aH --numeric-ids --delete "$OLD_DATA/" "$NEW_DATA/"
chown postgres:postgres "$NEW_DATA"
chmod 700 "$NEW_DATA"

if grep -Eq '^[[:space:]]*data_directory[[:space:]]*=' "$CONFIG"; then
  sed -Ei "s|^[[:space:]]*data_directory[[:space:]]*=.*$|data_directory = '${NEW_DATA}'|" "$CONFIG"
else
  printf "\ndata_directory = '%s'\n" "$NEW_DATA" >> "$CONFIG"
fi

log "Starting $SERVICE with the new data directory."
systemctl start "$SERVICE"

ACTUAL_DATA="$("${PSQL[@]}" --command='SHOW data_directory')"
ACTUAL_DATA="$(realpath -e "$ACTUAL_DATA")"
[[ "$ACTUAL_DATA" == "$NEW_DATA" ]] || \
  fail "PostgreSQL started with $ACTUAL_DATA instead of $NEW_DATA."

"${PSQL[@]}" --command='SELECT version();'
"${PSQL[@]}" --command='SELECT pg_is_in_recovery();'

ROLLBACK_REQUIRED=0
trap - EXIT
log "Migration completed successfully."
log "Configuration backup: $CONFIG_BACKUP"
log "Old data retained at: $OLD_DATA"
log "Do not remove the old directory until backup, application, logs, and replicas are verified."
