#!/usr/bin/env bash
set -euo pipefail
umask 077

ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env.load"
LABEL=${1:-"backup-$(date -u +%Y%m%d%H%M%S)"}

if [[ ! -f "$ENV_FILE" ]]; then
  echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2
  exit 1
fi

set -a
# shellcheck source=/dev/null
. "$ENV_FILE"
set +a

for name in LOAD_PROJECT POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD DATABASE_URL \
  BACKUP_MINIO_API_PORT BACKUP_MINIO_CONSOLE_PORT BACKUP_MINIO_ROOT_USER \
  BACKUP_MINIO_ROOT_PASSWORD BACKUP_RESTIC_PASSWORD BACKUP_BUCKET_PREFIX \
  BACKUP_TIMEOUT_SECONDS BACKUP_INTERRUPTION_CHUNKS \
  BACKUP_INTERRUPTION_CHUNK_BYTES BACKUP_INTERRUPTION_INTERVAL_SECONDS; do
  if [[ -z "${!name:-}" ]]; then
    echo "$name is missing from .env.load" >&2
    exit 1
  fi
done

if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then
  echo "The encrypted backup drill must not use the staging Compose project." >&2
  exit 1
fi

if [[ ! "$LABEL" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then
  echo "Run label must be 1-32 lowercase letters, numbers, or dashes." >&2
  exit 1
fi

if [[ ! "$BACKUP_BUCKET_PREFIX" =~ ^[a-z0-9][a-z0-9-]{1,19}$ ]]; then
  echo "BACKUP_BUCKET_PREFIX must be 2-20 lowercase letters, numbers, or dashes." >&2
  exit 1
fi

for name in BACKUP_MINIO_API_PORT BACKUP_MINIO_CONSOLE_PORT; do
  if [[ ! "${!name}" =~ ^[0-9]+$ ]] || ((10#${!name} > 65535)); then
    echo "$name must be a TCP port number from 0 through 65535." >&2
    exit 1
  fi
done

for name in BACKUP_TIMEOUT_SECONDS BACKUP_INTERRUPTION_CHUNKS \
  BACKUP_INTERRUPTION_CHUNK_BYTES; do
  if [[ ! "${!name}" =~ ^[1-9][0-9]*$ ]]; then
    echo "$name must be a positive integer." >&2
    exit 1
  fi
done

if ! awk -v value="$BACKUP_INTERRUPTION_INTERVAL_SECONDS" \
  'BEGIN {exit !(value ~ /^[0-9]+([.][0-9]+)?$/ && value > 0)}'; then
  echo "BACKUP_INTERRUPTION_INTERVAL_SECONDS must be greater than zero." >&2
  exit 1
fi

for command in awk dd diff docker grep head jq od sleep tr unlink; do
  if ! command -v "$command" >/dev/null 2>&1; then
    echo "Required command is unavailable: $command" >&2
    exit 1
  fi
done

bucket="$BACKUP_BUCKET_PREFIX-$LABEL"
corrupt_bucket="$bucket-corrupt"
interrupt_bucket="$bucket-interrupt"

for candidate in "$bucket" "$corrupt_bucket" "$interrupt_bucket"; do
  if ((${#candidate} > 63)); then
    echo "Derived S3 bucket name exceeds 63 characters: $candidate" >&2
    exit 1
  fi
done

runtime_dir="$ROOT/tmp/backup-s3/$LOAD_PROJECT/$LABEL"
output_dir="$ROOT/output/backups-s3/$LABEL"
mkdir -p "$runtime_dir" "$output_dir"
chmod 700 "$ROOT/tmp" "$ROOT/tmp/backup-s3" \
  "$ROOT/tmp/backup-s3/$LOAD_PROJECT" "$runtime_dir" \
  "$ROOT/output" "$ROOT/output/backups-s3" "$output_dir"

export BACKUP_RUNTIME_DIR="$runtime_dir"
export BACKUP_HOST_UID
export BACKUP_HOST_GID
BACKUP_HOST_UID=$(id -u)
BACKUP_HOST_GID=$(id -g)

compose=(
  docker compose
  --env-file "$ENV_FILE"
  -p "$LOAD_PROJECT"
  -f compose.yaml
  -f compose.load.yaml
  -f compose.backup.yaml
  --profile backup
)

service_ids() {
  "${compose[@]}" ps --all -q "$1"
}

assert_scope() {
  local container_id=$1
  local expected_service=$2
  local observed_project observed_service

  observed_project=$(
    docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' \
      "$container_id"
  )
  observed_service=$(
    docker inspect --format '{{index .Config.Labels "com.docker.compose.service"}}' \
      "$container_id"
  )

  if [[ "$observed_project" != "$LOAD_PROJECT" ||
    "$observed_service" != "$expected_service" ]]; then
    echo "Container scope mismatch for $container_id." >&2
    exit 1
  fi
}

mc_run() {
  "${compose[@]}" run --rm --no-deps -T minio-client "$@"
}

restic_run_repo() {
  local repository=$1
  shift
  "${compose[@]}" run --rm --no-deps -T \
    --env "RESTIC_REPOSITORY=$repository" \
    backup-tools restic "$@"
}

database_snapshot() {
  # Variables are intentionally expanded inside the isolated PostGIS container.
  # shellcheck disable=SC2016
  "${compose[@]}" exec -T db sh -c \
    'psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \
      --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' >"$1" <<'SQL'
BEGIN READ ONLY;
SELECT 'users' AS table_name, count(*) AS row_count FROM users
UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens
UNION ALL SELECT 'help_requests', count(*) FROM help_requests
UNION ALL SELECT 'messages', count(*) FROM messages
UNION ALL SELECT 'categories', count(*) FROM categories
UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments
UNION ALL SELECT 'activities', count(*) FROM activities
UNION ALL SELECT 'reports', count(*) FROM reports
UNION ALL SELECT 'social_identities', count(*) FROM social_identities
UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions
UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions
UNION ALL SELECT 'schema_migrations', count(*) FROM schema_migrations
ORDER BY table_name;
COMMIT;
SQL
}

wait_for_minio() {
  local minio_id=$1
  local deadline=$((SECONDS + BACKUP_TIMEOUT_SECONDS))

  while ((SECONDS < deadline)); do
    local state health
    state=$(docker inspect --format '{{.State.Status}}' "$minio_id")
    health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' \
      "$minio_id")

    if [[ "$state" == "running" && "$health" == "healthy" ]]; then
      return 0
    fi

    sleep 1
  done

  echo "Timed out waiting for the scoped MinIO container." >&2
  return 1
}

remove_bucket() {
  local candidate=$1
  mc_run rb --force "local/$candidate" >/dev/null 2>&1 || true
}

remove_bucket_and_verify() {
  local candidate=$1

  mc_run rb --force "local/$candidate" >/dev/null

  if mc_run stat "local/$candidate" >/dev/null 2>&1; then
    echo "Temporary MinIO bucket was not removed: $candidate" >&2
    exit 1
  fi
}

remove_interrupt_container() {
  if [[ -n "${interrupt_container_id:-}" ]] &&
    docker inspect "$interrupt_container_id" >/dev/null 2>&1; then
    assert_scope "$interrupt_container_id" backup-tools
    docker stop --time 1 "$interrupt_container_id" >/dev/null 2>&1 || true
    docker rm "$interrupt_container_id" >/dev/null 2>&1 || true
  fi
}

drop_drill_database() {
  if [[ "${drill_database_created:-false}" == "true" ]]; then
    "${compose[@]}" exec -T db \
      dropdb --username "$POSTGRES_USER" --if-exists "$drill_db" >/dev/null 2>&1 || true
  fi
}

cleanup() {
  local status=$?
  trap - EXIT HUP INT TERM
  drop_drill_database
  remove_interrupt_container

  if [[ "${corrupt_bucket_created:-false}" == "true" ]]; then
    remove_bucket "$corrupt_bucket"
  fi

  if [[ "${interrupt_bucket_created:-false}" == "true" ]]; then
    remove_bucket "$interrupt_bucket"
  fi

  exit "$status"
}

trap cleanup EXIT HUP INT TERM

for service in minio-permissions minio minio-client backup-tools; do
  while IFS= read -r existing_id; do
    [[ -n "$existing_id" ]] && assert_scope "$existing_id" "$service"
  done < <(service_ids "$service")
done

database_snapshot "$output_dir/database-before.txt"

"${compose[@]}" build backup-tools minio minio-client \
  >"$output_dir/backup-images-build.txt"
"${compose[@]}" up -d --wait minio >"$output_dir/minio-up.txt"
minio_id=$(service_ids minio | head -n 1)
assert_scope "$minio_id" minio
wait_for_minio "$minio_id"

if mc_run stat "local/$bucket" >/dev/null 2>&1; then
  echo "Refusing to overwrite retained canonical bucket: $bucket" >&2
  exit 1
fi

mc_run mb "local/$bucket" >"$output_dir/canonical-bucket-create.txt"
canonical_repository="s3:http://minio:9000/$bucket"
restic_run_repo "$canonical_repository" init >"$output_dir/restic-init.txt"

restic_run_repo "$canonical_repository" \
  backup \
  --json \
  --host who-need-help-local \
  --tag "$LABEL" \
  --stdin-filename database.dump \
  --stdin-from-command \
  -- \
  pg_dump \
  --host db \
  --username "$POSTGRES_USER" \
  --dbname "$POSTGRES_DB" \
  --format custom \
  --no-owner \
  >"$output_dir/backup.jsonl"

restic_run_repo "$canonical_repository" snapshots --json --tag "$LABEL" \
  >"$output_dir/snapshots.json"

if ! jq -e --arg label "$LABEL" '
  length == 1 and
  .[0].tags == [$label] and
  (.[0].id | length) == 64
' "$output_dir/snapshots.json" >/dev/null; then
  echo "The canonical encrypted backup did not create exactly one tagged snapshot." >&2
  exit 1
fi

snapshot_id=$(jq -r '.[0].id' "$output_dir/snapshots.json")

restic_run_repo "$canonical_repository" check --read-data \
  >"$output_dir/canonical-check.txt" 2>&1
restic_run_repo "$canonical_repository" ls --json "$snapshot_id" \
  >"$output_dir/snapshot-files.jsonl"

if ! jq -s -e '
  any(
    .[];
    .struct_type == "node" and
    .path == "/database.dump" and
    .type == "file" and
    .size > 0
  )
' "$output_dir/snapshot-files.jsonl" >/dev/null; then
  echo "The encrypted snapshot does not contain a non-empty database.dump." >&2
  exit 1
fi

mc_run ls --recursive --json "local/$bucket" \
  >"$output_dir/minio-objects.jsonl"

if ! jq -s -e '
  length > 0 and
  any(.[]; (.key // "") | startswith("data/"))
' "$output_dir/minio-objects.jsonl" >/dev/null; then
  echo "MinIO did not retain encrypted Restic data objects." >&2
  exit 1
fi

pack_key=$(
  jq -sr '
    [.[] | select((.key // "") | startswith("data/")) | .key]
    | sort
    | first
  ' "$output_dir/minio-objects.jsonl"
)

mc_run cat "local/$bucket/$pack_key" |
  dd of="$runtime_dir/encrypted-prefix.bin" bs=1 count=16 status=none
od -An -tx1 -v "$runtime_dir/encrypted-prefix.bin" |
  tr -d ' \n' >"$output_dir/encrypted-prefix.hex"

if grep -aFq 'PGDMP' "$runtime_dir/encrypted-prefix.bin"; then
  echo "A Restic data object exposed the PostgreSQL custom-format header." >&2
  exit 1
fi

restic_run_repo "$canonical_repository" dump "$snapshot_id" /database.dump |
  "${compose[@]}" exec -T db pg_restore --list \
    >"$output_dir/pg-restore-list.txt"

drill_db="wnh_s3_restore_${LABEL//-/_}_$$"
"${compose[@]}" exec -T db \
  createdb \
  --username "$POSTGRES_USER" \
  --template template0 \
  "$drill_db"
drill_database_created=true

restic_run_repo "$canonical_repository" dump "$snapshot_id" /database.dump |
  "${compose[@]}" exec -T db \
    pg_restore \
    --username "$POSTGRES_USER" \
    --dbname "$drill_db" \
    --exit-on-error \
    --no-owner \
    --no-privileges

"${compose[@]}" exec -T db \
  psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \
  --username "$POSTGRES_USER" --dbname "$drill_db" \
  >"$output_dir/restored-database.json" <<'SQL'
SELECT json_build_object(
  'public_table_count',
    (
      SELECT count(*)
      FROM pg_tables
      WHERE schemaname = 'public'
        AND tablename <> 'spatial_ref_sys'
    ),
  'migration_count', (SELECT count(*) FROM schema_migrations),
  'user_count', (SELECT count(*) FROM users),
  'request_count', (SELECT count(*) FROM help_requests),
  'message_count', (SELECT count(*) FROM messages),
  'category_count', (SELECT count(*) FROM categories),
  'postgis_version', PostGIS_Lib_Version()
);
SQL

if ! jq -e '
  .public_table_count > 0 and
  .migration_count > 0 and
  .category_count > 0 and
  (.postgis_version | type == "string" and length > 0)
' "$output_dir/restored-database.json" >/dev/null; then
  echo "The fresh restore database failed structural validation." >&2
  exit 1
fi

case "$DATABASE_URL" in
  *\?*)
    database_base=${DATABASE_URL%%\?*}
    database_query="?${DATABASE_URL#*\?}"
    ;;

  *)
    database_base=$DATABASE_URL
    database_query=
    ;;
esac

database_prefix=${database_base%/*}

if [[ "$database_prefix" == "$database_base" ]]; then
  echo "Could not derive the temporary drill DATABASE_URL." >&2
  exit 1
fi

drill_database_url="$database_prefix/$drill_db$database_query"

"${compose[@]}" run --rm --no-deps \
  --env APP_ROLE=migrate \
  --env "DATABASE_URL=$drill_database_url" \
  migrate /app/bin/migrate \
  >"$output_dir/restore-migrate.txt"

"${compose[@]}" run --rm --no-deps \
  --env APP_ROLE=migrate \
  --env "DATABASE_URL=$drill_database_url" \
  migrate /app/bin/who_need_help eval 'WhoNeedHelp.Release.await_migrations()' \
  >"$output_dir/restore-migration-readiness.txt"

"${compose[@]}" exec -T db \
  dropdb --username "$POSTGRES_USER" "$drill_db"
drill_database_created=false

remaining=$(
  "${compose[@]}" exec -T db \
    psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \
    --username "$POSTGRES_USER" --dbname postgres \
    --command "SELECT count(*) FROM pg_database WHERE datname = '$drill_db';"
)

if [[ "$remaining" != "0" ]]; then
  echo "Restore drill database was not removed: $drill_db" >&2
  exit 1
fi

mc_run mb "local/$corrupt_bucket" >"$output_dir/corrupt-bucket-create.txt"
corrupt_bucket_created=true
mc_run mirror "local/$bucket" "local/$corrupt_bucket" \
  >"$output_dir/corrupt-mirror.txt"
mc_run cp "local/$corrupt_bucket/config" /work/corrupt-config \
  >"$output_dir/corrupt-download.txt"

first_byte=$(od -An -tu1 -N1 "$runtime_dir/corrupt-config" | tr -d ' ')

if [[ "$first_byte" == "0" ]]; then
  printf '\001' | dd of="$runtime_dir/corrupt-config" bs=1 count=1 conv=notrunc status=none
else
  printf '\000' | dd of="$runtime_dir/corrupt-config" bs=1 count=1 conv=notrunc status=none
fi

mc_run cp /work/corrupt-config "local/$corrupt_bucket/config" \
  >"$output_dir/corrupt-upload.txt"
corrupt_repository="s3:http://minio:9000/$corrupt_bucket"

set +e
restic_run_repo "$corrupt_repository" check --read-data \
  >"$output_dir/corrupt-check.txt" 2>&1
corrupt_check_status=$?
restic_run_repo "$corrupt_repository" dump "$snapshot_id" /database.dump \
  >"/dev/null" 2>"$output_dir/corrupt-restore.txt"
corrupt_restore_status=$?
set -e

if [[ "$corrupt_check_status" -eq 0 || "$corrupt_restore_status" -eq 0 ]]; then
  echo "The corrupted encrypted repository did not fail closed." >&2
  exit 1
fi

remove_bucket_and_verify "$corrupt_bucket"
corrupt_bucket_created=false
unlink "$runtime_dir/corrupt-config"

mc_run mb "local/$interrupt_bucket" >"$output_dir/interrupt-bucket-create.txt"
interrupt_bucket_created=true
interrupt_repository="s3:http://minio:9000/$interrupt_bucket"
restic_run_repo "$interrupt_repository" init >"$output_dir/interrupt-init.txt"

interrupt_container="$LOAD_PROJECT-backup-interrupt-$LABEL"

if docker inspect "$interrupt_container" >/dev/null 2>&1; then
  echo "Refusing to replace an existing interruption container: $interrupt_container" >&2
  exit 1
fi

# The producer variables must expand inside the isolated backup-tools container.
# shellcheck disable=SC2016
interrupt_container_id=$(
  "${compose[@]}" run -d \
    --name "$interrupt_container" \
    --no-deps \
    --env "RESTIC_REPOSITORY=$interrupt_repository" \
    backup-tools \
    restic backup \
    --json \
    --host who-need-help-local \
    --tag "$LABEL-interrupted" \
    --stdin-filename interrupted.bin \
    --stdin-from-command \
    -- \
    /bin/sh -euc \
    'i=0
     while [ "$i" -lt "$BACKUP_INTERRUPTION_CHUNKS" ]; do
       head -c "$BACKUP_INTERRUPTION_CHUNK_BYTES" /dev/urandom
       sleep "$BACKUP_INTERRUPTION_INTERVAL_SECONDS"
       i=$((i + 1))
     done'
)
assert_scope "$interrupt_container_id" backup-tools

upload_deadline=$((SECONDS + BACKUP_TIMEOUT_SECONDS))
uploaded_data_objects=0

while ((SECONDS < upload_deadline)); do
  mc_run ls --recursive --json "local/$interrupt_bucket/data" \
    >"$output_dir/interrupt-objects-current.jsonl" 2>/dev/null || true
  uploaded_data_objects=$(
    jq -s '[.[] | select(.type == "file")] | length' \
      "$output_dir/interrupt-objects-current.jsonl"
  )

  if ((uploaded_data_objects > 0)); then
    break
  fi

  if [[ "$(docker inspect --format '{{.State.Running}}' "$interrupt_container_id")" != "true" ]]; then
    echo "Interruption producer exited before an uploaded data object was observed." >&2
    exit 1
  fi

  sleep 1
done

if ((uploaded_data_objects == 0)); then
  echo "Timed out waiting for the interruption upload to reach MinIO." >&2
  exit 1
fi

docker stop --time 1 "$interrupt_container_id" >"$output_dir/interrupt-stop.txt"
docker inspect "$interrupt_container_id" |
  jq '.[0] | {
    id: .Id,
    project: .Config.Labels["com.docker.compose.project"],
    service: .Config.Labels["com.docker.compose.service"],
    exit_code: .State.ExitCode,
    state: .State.Status
  }' >"$output_dir/interrupt-container.json"
docker logs "$interrupt_container_id" >"$output_dir/interrupt-backup.log" 2>&1

if ! jq -e '.exit_code != 0 and .state == "exited"' \
  "$output_dir/interrupt-container.json" >/dev/null; then
  echo "The interrupted upload container did not exit unsuccessfully." >&2
  exit 1
fi

restic_run_repo "$interrupt_repository" snapshots \
  --json --tag "$LABEL-interrupted" \
  >"$output_dir/interrupt-snapshots.json"

if ! jq -e 'length == 0' "$output_dir/interrupt-snapshots.json" >/dev/null; then
  echo "An interrupted upload published a restorable snapshot." >&2
  exit 1
fi

mc_run ls --incomplete --recursive --json "local/$interrupt_bucket" \
  >"$output_dir/interrupt-incomplete-uploads.jsonl" 2>/dev/null || true
restic_run_repo "$interrupt_repository" check --read-data \
  >"$output_dir/interrupt-check-before-prune.txt" 2>&1
restic_run_repo "$interrupt_repository" prune \
  >"$output_dir/interrupt-prune.txt" 2>&1
restic_run_repo "$interrupt_repository" check --read-data \
  >"$output_dir/interrupt-check-after-prune.txt" 2>&1

docker rm "$interrupt_container_id" >"$output_dir/interrupt-remove.txt"
interrupt_container_id=
remove_bucket_and_verify "$interrupt_bucket"
interrupt_bucket_created=false

database_snapshot "$output_dir/database-after.txt"

if ! diff -u "$output_dir/database-before.txt" "$output_dir/database-after.txt" \
  >"$output_dir/database-diff.txt"; then
  echo "The encrypted backup drill changed source database counts." >&2
  exit 1
fi

minio_api=$("${compose[@]}" port minio 9000 | head -n 1)
minio_console=$("${compose[@]}" port minio 9001 | head -n 1)

if [[ ! "$minio_api" =~ ^127[.]0[.]0[.]1:[1-9][0-9]*$ ||
  ! "$minio_console" =~ ^127[.]0[.]0[.]1:[1-9][0-9]*$ ]]; then
  echo "MinIO ports are not bound to observed loopback addresses." >&2
  exit 1
fi

jq -n \
  --arg run_id "$LABEL" \
  --arg bucket "$bucket" \
  --arg snapshot_id "$snapshot_id" \
  --arg minio_api "http://$minio_api" \
  --arg minio_console "http://$minio_console" \
  --argjson corrupt_check_exit "$corrupt_check_status" \
  --argjson corrupt_restore_exit "$corrupt_restore_status" \
  --argjson interrupted_uploaded_objects "$uploaded_data_objects" \
  '{
    run_id: $run_id,
    retained_bucket: $bucket,
    snapshot_id: $snapshot_id,
    encrypted_repository_check: "passed",
    fresh_database_restore: "passed",
    source_database_count_diff_bytes: 0,
    corrupted_check_exit: $corrupt_check_exit,
    corrupted_restore_exit: $corrupt_restore_exit,
    interrupted_uploaded_objects: $interrupted_uploaded_objects,
    interrupted_snapshot_count: 0,
    interrupted_repository_check_after_prune: "passed",
    minio_api: $minio_api,
    minio_console: $minio_console
  }' >"$output_dir/summary.json"

trap - EXIT HUP INT TERM

printf 'Encrypted S3 backup evidence: %s\n' "$output_dir"
printf 'Retained MinIO bucket: %s\nSnapshot: %s\n' "$bucket" "$snapshot_id"
printf 'MinIO API: http://%s\nMinIO console: http://%s\n' \
  "$minio_api" "$minio_console"
