#!/usr/bin/env bash

# ============================================================================
# PostgreSQL OneKey Backup
#
# Version: v1.0.0
# Author:  Leon (silenceace@gmail.com)
# Repo:    https://github.com/funnyzak/pgsql-onekey-backup
# License: MIT
#
# Requirements: Bash 4.2+, PostgreSQL client tools, flock, sha256sum/shasum,
# zip. curl is required when an HTTP notification channel is configured;
# apprise plus timeout/gtimeout are required only for the Apprise CLI channel.
#
# Basic example:
#   bash pgsql_backup.sh \
#     --target-dir /var/backups/postgresql \
#     --passfile /etc/postgresql-backup/pgpass \
#     --database app --database analytics
#
# Password environment-variable example (preferred over --password for Cron):
#   export PGSQL_BACKUP_PASSWORD='replace-with-your-password'
#   bash pgsql_backup.sh \
#     --target-dir /var/backups/postgresql \
#     --host 127.0.0.1 --port 5432 --user backup \
#     --database app
#
# Password command-line example (may be visible in ps and shell history):
#   bash pgsql_backup.sh \
#     --target-dir /var/backups/postgresql \
#     --host 127.0.0.1 --port 5432 --user backup \
#     --password 'replace-with-your-password' \
#     --database app
#
# Check configuration without connecting, backing up, or sending notifications:
#   bash pgsql_backup.sh --target-dir /var/backups/postgresql --check
#
# Every setting supports an environment variable and a command-line option.
# Command-line values override environment variables. Configure only the
# notification channels you need:
#   export PGSQL_BACKUP_BARK_SERVER=https://api.day.app
#   export PGSQL_BACKUP_BARK_DEVICE_KEY=replace-with-device-key
#   export PGSQL_BACKUP_BARK_SOUND=alarm                 # optional
#   export PGSQL_BACKUP_BARK_GROUP=postgresql-backup          # optional
#   export PGSQL_BACKUP_NTFY_SERVER=https://ntfy.sh
#   export PGSQL_BACKUP_NTFY_TOPIC=postgresql-backup
#   export PGSQL_BACKUP_NTFY_TOKEN=replace-with-token    # optional
#   export PGSQL_BACKUP_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/replace-me
#   export PGSQL_BACKUP_WECOM_WEBHOOK_URL='https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me'
#   export PGSQL_BACKUP_DINGTALK_WEBHOOK_URL='https://oapi.dingtalk.com/robot/send?access_token=replace-me'
#   export PGSQL_BACKUP_GOTIFY_SERVER=https://gotify.example.com
#   export PGSQL_BACKUP_GOTIFY_TOKEN=replace-with-app-token
#   export PGSQL_BACKUP_GOTIFY_PRIORITY=5                # optional
#   export PGSQL_BACKUP_NOTIFY_REDACT=true               # optional, default
#   export PGSQL_BACKUP_NOTIFY_TIMEOUT=10                # optional, 1-300
#   bash pgsql_backup.sh --target-dir /var/backups/postgresql --database app
#
# The same Bark settings can be written as command-line options:
#   bash pgsql_backup.sh \
#     --target-dir /var/backups/postgresql --database app \
#     --bark-server https://api.day.app \
#     --bark-device-key replace-with-device-key \
#     --bark-sound alarm --bark-group postgresql-backup --notify-timeout 10
#
# Apprise API supports both stateful and stateless curl calls:
#   export PGSQL_BACKUP_APPRISE_API_URL=http://127.0.0.1:8000/notify/postgresql-backup
#   # For stateless /notify, also set PGSQL_BACKUP_APPRISE_URLS.
# Apprise CLI remains available when PGSQL_BACKUP_APPRISE_CONFIG points to a
# protected Apprise configuration file. PGSQL_BACKUP_APPRISE_TAGS accepts a
# comma-separated tag expression.
#   export PGSQL_BACKUP_APPRISE_CONFIG=/etc/postgresql-backup/apprise.conf
#   export PGSQL_BACKUP_APPRISE_TAGS=ops,admin            # optional
# CLI equivalents: --apprise-config, --apprise-api-url, --apprise-urls,
# --apprise-tags, --ntfy-*, --feishu-webhook-url, --wecom-webhook-url,
# --dingtalk-webhook-url, and --gotify-*.
#
# Cron uses one physical line. Quote URLs and tokens. Escape every percent sign
# as \% because Cron processes percent signs before the shell does:
#   0 3 * * * /usr/bin/bash /opt/postgresql-backup/pgsql_backup.sh --target-dir /var/backups/postgresql --database app --bark-server 'https://api.day.app' --bark-device-key 'replace-me'
#
# Default notifications: success and failure. Add --notify-start to also send
# the start event. See README.md and --help for complete usage.
# ============================================================================

set +x
set -Eeuo pipefail
set +a
umask 077

readonly SCRIPT_VERSION="v1.0.0"
readonly PROGRAM_NAME="pgsql-onekey-backup"
readonly MAX_DATABASE_SLUG_BYTES=160

TARGET_DIR=${PGSQL_BACKUP_TARGET_DIR:-}
DB_HOST=${PGSQL_BACKUP_HOST:-127.0.0.1}
DB_PORT=${PGSQL_BACKUP_PORT:-5432}
DB_USER=${PGSQL_BACKUP_USER:-postgres}
DB_PASSWORD=${PGSQL_BACKUP_PASSWORD:-}
MAINTENANCE_DATABASE=${PGSQL_BACKUP_MAINTENANCE_DATABASE:-postgres}
PASSFILE=${PGSQL_BACKUP_PASSFILE:-}
SERVICE_FILE=${PGSQL_BACKUP_SERVICE_FILE:-}
SERVICE=${PGSQL_BACKUP_SERVICE:-}
FORMAT=${PGSQL_BACKUP_FORMAT:-custom}
OUTPUT_MODE=${PGSQL_BACKUP_OUTPUT_MODE:-zip}
COMPRESSION=${PGSQL_BACKUP_COMPRESSION:-6}
COMPRESSION_EXPLICIT=false
[[ -n ${PGSQL_BACKUP_COMPRESSION+x} ]] && COMPRESSION_EXPLICIT=true
GLOBALS_MODE=${PGSQL_BACKUP_GLOBALS_MODE:-auto}
INCLUDE_ROLE_PASSWORDS=${PGSQL_BACKUP_INCLUDE_ROLE_PASSWORDS:-false}
EXPIRE_HOURS=${PGSQL_BACKUP_EXPIRE_HOURS:-4320}
SERVER_NAME=${PGSQL_BACKUP_SERVER_NAME:-$(hostname 2>/dev/null || printf 'unknown')}
NOTIFY_START=${PGSQL_BACKUP_NOTIFY_START:-false}
NOTIFY_REDACT=${PGSQL_BACKUP_NOTIFY_REDACT:-true}
NOTIFY_TIMEOUT=${PGSQL_BACKUP_NOTIFY_TIMEOUT:-10}
BEFORE_HOOK=${PGSQL_BACKUP_BEFORE_HOOK:-}
AFTER_DATABASE_HOOK=${PGSQL_BACKUP_AFTER_DATABASE_HOOK:-}
AFTER_HOOK=${PGSQL_BACKUP_AFTER_HOOK:-}
DATABASE_VALUES=${PGSQL_BACKUP_DATABASES:-}
DUMP_OPTION_VALUES=${PGSQL_BACKUP_DUMP_OPTIONS:-}
CHECK_ONLY=${PGSQL_BACKUP_CHECK:-false}
DEPENDENCY_REPORT=false

APPRISE_CLI_CONFIG=${PGSQL_BACKUP_APPRISE_CONFIG:-}
APPRISE_API_URL=${PGSQL_BACKUP_APPRISE_API_URL:-}
APPRISE_API_URLS=${PGSQL_BACKUP_APPRISE_URLS:-}
APPRISE_TAGS=${PGSQL_BACKUP_APPRISE_TAGS:-}
BARK_SERVER=${PGSQL_BACKUP_BARK_SERVER:-}
BARK_DEVICE_KEY=${PGSQL_BACKUP_BARK_DEVICE_KEY:-}
BARK_SOUND=${PGSQL_BACKUP_BARK_SOUND:-}
BARK_GROUP=${PGSQL_BACKUP_BARK_GROUP:-}
NTFY_SERVER=${PGSQL_BACKUP_NTFY_SERVER:-}
NTFY_TOPIC=${PGSQL_BACKUP_NTFY_TOPIC:-}
NTFY_TOKEN=${PGSQL_BACKUP_NTFY_TOKEN:-}
FEISHU_WEBHOOK_URL=${PGSQL_BACKUP_FEISHU_WEBHOOK_URL:-}
WECOM_WEBHOOK_URL=${PGSQL_BACKUP_WECOM_WEBHOOK_URL:-}
DINGTALK_WEBHOOK_URL=${PGSQL_BACKUP_DINGTALK_WEBHOOK_URL:-}
GOTIFY_SERVER=${PGSQL_BACKUP_GOTIFY_SERVER:-}
GOTIFY_TOKEN=${PGSQL_BACKUP_GOTIFY_TOKEN:-}
GOTIFY_PRIORITY=${PGSQL_BACKUP_GOTIFY_PRIORITY:-5}

DB_HOST_EXPLICIT=false
DB_PORT_EXPLICIT=false
DB_USER_EXPLICIT=false
[[ -n ${PGSQL_BACKUP_HOST+x} ]] && DB_HOST_EXPLICIT=true
[[ -n ${PGSQL_BACKUP_PORT+x} ]] && DB_PORT_EXPLICIT=true
[[ -n ${PGSQL_BACKUP_USER+x} ]] && DB_USER_EXPLICIT=true
PASSWORD_CONFIGURED=false
[[ -n $DB_PASSWORD ]] && PASSWORD_CONFIGURED=true

export -n TARGET_DIR DB_HOST DB_PORT DB_USER DB_PASSWORD MAINTENANCE_DATABASE PASSFILE SERVICE_FILE SERVICE FORMAT OUTPUT_MODE COMPRESSION GLOBALS_MODE INCLUDE_ROLE_PASSWORDS EXPIRE_HOURS SERVER_NAME NOTIFY_START NOTIFY_REDACT NOTIFY_TIMEOUT BEFORE_HOOK AFTER_DATABASE_HOOK AFTER_HOOK DATABASE_VALUES DUMP_OPTION_VALUES CHECK_ONLY APPRISE_CLI_CONFIG APPRISE_API_URL APPRISE_API_URLS APPRISE_TAGS BARK_SERVER BARK_DEVICE_KEY BARK_SOUND BARK_GROUP NTFY_SERVER NTFY_TOPIC NTFY_TOKEN FEISHU_WEBHOOK_URL WECOM_WEBHOOK_URL DINGTALK_WEBHOOK_URL GOTIFY_SERVER GOTIFY_TOKEN GOTIFY_PRIORITY
unset PGSQL_BACKUP_TARGET_DIR PGSQL_BACKUP_HOST PGSQL_BACKUP_PORT PGSQL_BACKUP_USER PGSQL_BACKUP_PASSWORD PGSQL_BACKUP_MAINTENANCE_DATABASE PGSQL_BACKUP_PASSFILE PGSQL_BACKUP_SERVICE_FILE PGSQL_BACKUP_SERVICE PGSQL_BACKUP_DATABASES PGSQL_BACKUP_DUMP_OPTIONS PGSQL_BACKUP_FORMAT PGSQL_BACKUP_OUTPUT_MODE PGSQL_BACKUP_COMPRESSION PGSQL_BACKUP_GLOBALS_MODE PGSQL_BACKUP_INCLUDE_ROLE_PASSWORDS PGSQL_BACKUP_EXPIRE_HOURS PGSQL_BACKUP_SERVER_NAME PGSQL_BACKUP_NOTIFY_START PGSQL_BACKUP_NOTIFY_REDACT PGSQL_BACKUP_NOTIFY_TIMEOUT PGSQL_BACKUP_BEFORE_HOOK PGSQL_BACKUP_AFTER_DATABASE_HOOK PGSQL_BACKUP_AFTER_HOOK PGSQL_BACKUP_CHECK PGSQL_BACKUP_APPRISE_CONFIG PGSQL_BACKUP_APPRISE_API_URL PGSQL_BACKUP_APPRISE_URLS PGSQL_BACKUP_APPRISE_TAGS PGSQL_BACKUP_BARK_SERVER PGSQL_BACKUP_BARK_DEVICE_KEY PGSQL_BACKUP_BARK_SOUND PGSQL_BACKUP_BARK_GROUP PGSQL_BACKUP_NTFY_SERVER PGSQL_BACKUP_NTFY_TOPIC PGSQL_BACKUP_NTFY_TOKEN PGSQL_BACKUP_FEISHU_WEBHOOK_URL PGSQL_BACKUP_WECOM_WEBHOOK_URL PGSQL_BACKUP_DINGTALK_WEBHOOK_URL PGSQL_BACKUP_GOTIFY_SERVER PGSQL_BACKUP_GOTIFY_TOKEN PGSQL_BACKUP_GOTIFY_PRIORITY

declare -a DATABASES=() DUMP_OPTIONS=() DATABASE_FILES=() DATABASE_STARTS=() DATABASE_ENDS=() DATABASE_SIZES=()
declare -a NOTIFICATION_NAMES=() NOTIFICATION_SENDERS=() NOTIFICATION_VALIDATORS=() MISSING_DEPENDENCIES=() MISSING_DEPENDENCY_KINDS=()
DATABASE_COUNT=0
CLI_DATABASES_SET=false
CLI_DUMP_OPTIONS_SET=false
ALL_DATABASES_REQUESTED=false
NOTIFICATION_CHANNEL_COUNT=0
TIMEOUT_COMMAND=
RUN_ID=
STAGE_DIR=
PUBLISHED_DIR=
ERROR_LOG=
CURRENT_PHASE=initialization
START_EPOCH=0
FINAL_MESSAGE=
NOTIFY_ON_EXIT=false
CLIENT_VERSION=
SERVER_VERSION=
ACTUAL_GLOBALS_MODE=skip
CONNECTION_SOURCE=arguments
CREDENTIAL_SOURCE=default

usage() {
  cat <<'EOF'
Usage: pgsql_backup.sh [options]

Required:
  --target-dir DIR             Existing private backup root directory

Database:
  --host HOST                  PostgreSQL host (default: 127.0.0.1)
  --port PORT                  PostgreSQL port (default: 5432)
  --user USER                  PostgreSQL user (default: postgres)
  --password PASSWORD          Direct password (prefer environment variable)
  --database NAME              Database to back up; repeat as needed
  --all-databases              Discover all connectable non-template databases
  --maintenance-database NAME  Maintenance database (default: postgres)
  --passfile FILE              Protected libpq password file
  --service-file FILE          Protected libpq service file
  --service NAME               libpq service name
  --dump-option OPTION         Allowlisted pg_dump option; repeat as needed
  --default-dump-options       Clear environment dump options

Output:
  --format MODE                custom or plain (default: custom)
  --output-mode MODE           zip, files, or both (default: zip)
  --compression LEVEL          custom compression level, 0-9 (default: 6)
  --globals-mode MODE          auto, include, or skip (default: auto)
  --include-role-passwords     Include role password hashes (high risk)
  --no-include-role-passwords  Exclude role password hashes (default)
  --expire-hours HOURS         Remove managed runs older than HOURS
  --before-hook FILE           Executable run before database dumps
  --after-database-hook FILE   Executable run after each database dump
  --after-hook FILE            Executable run after publishing and pruning

Notifications:
  --notify-start / --no-notify-start
  --notify-redact / --no-notify-redact
  --notify-timeout SECONDS
  --apprise-config FILE        --apprise-api-url URL
  --apprise-urls URLS          --apprise-tags TAGS
  --bark-server URL            --bark-device-key KEY
  --bark-sound SOUND           --bark-group GROUP
  --ntfy-server URL            --ntfy-topic TOPIC --ntfy-token TOKEN
  --feishu-webhook-url URL     --wecom-webhook-url URL
  --dingtalk-webhook-url URL   --gotify-server URL
  --gotify-token TOKEN         --gotify-priority NUMBER

Other:
  --server-name NAME           Label shown in notifications
  --check / --no-check         Offline configuration check
  --dependency-report          Show dependencies without database access
  -h, --help                   Show help
  -V, --version                Show version

All settings also support the PGSQL_BACKUP_* environment variables documented
in README.md. List environment values contain one item per line. CLI lists
replace their environment counterparts. Only "--option value" is accepted.
EOF
}

log() { local level=$1; shift; printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$*"; }
die() { FINAL_MESSAGE=$*; log ERROR "$*" >&2; exit 2; }
require_value() { [[ $2 -ge 2 ]] || die "$1 requires a value"; }

validate_dump_option() {
  local option=$1
  ! value_has_control_characters "$option" || die "Dump options cannot contain control characters"
  case "$option" in
    --data-only|--schema-only|--blobs|--no-blobs|--no-owner|--no-privileges|--no-comments|--no-security-labels|--no-publications|--no-subscriptions|--inserts|--column-inserts|--on-conflict-do-nothing|--verbose|--no-synchronized-snapshots) ;;
    --schema=?*|--exclude-schema=?*|--table=?*|--exclude-table=?*|--exclude-table-data=?*|--rows-per-insert=?*|--lock-wait-timeout=?*) ;;
    *) die "Unsupported --dump-option: $option" ;;
  esac
}

load_environment_lists() {
  local value
  if [[ -n $DATABASE_VALUES ]]; then
    while IFS= read -r value; do [[ -n $value ]] || die "Database list contains an empty value"; DATABASES+=("$value"); done <<< "$DATABASE_VALUES"
  fi
  if [[ -n $DUMP_OPTION_VALUES ]]; then
    while IFS= read -r value; do [[ -n $value ]] || die "Dump option list contains an empty value"; DUMP_OPTIONS+=("$value"); done <<< "$DUMP_OPTION_VALUES"
  fi
  DATABASE_COUNT=${#DATABASES[@]}
  [[ $DATABASE_COUNT -eq 0 ]] && ALL_DATABASES_REQUESTED=true
}

parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --target-dir) require_value "$1" "$#"; TARGET_DIR=$2; shift 2 ;;
      --host) require_value "$1" "$#"; DB_HOST=$2; DB_HOST_EXPLICIT=true; shift 2 ;;
      --port) require_value "$1" "$#"; DB_PORT=$2; DB_PORT_EXPLICIT=true; shift 2 ;;
      --user) require_value "$1" "$#"; DB_USER=$2; DB_USER_EXPLICIT=true; shift 2 ;;
      --password) require_value "$1" "$#"; [[ -n $2 ]] || die "--password cannot be empty"; DB_PASSWORD=$2; PASSWORD_CONFIGURED=true; shift 2 ;;
      --database) require_value "$1" "$#"; if [[ $CLI_DATABASES_SET == false ]]; then DATABASES=(); CLI_DATABASES_SET=true; fi; ALL_DATABASES_REQUESTED=false; [[ -n $2 ]] || die "--database cannot be empty"; DATABASES+=("$2"); shift 2 ;;
      --all-databases) DATABASES=(); CLI_DATABASES_SET=true; ALL_DATABASES_REQUESTED=true; shift ;;
      --maintenance-database) require_value "$1" "$#"; MAINTENANCE_DATABASE=$2; shift 2 ;;
      --passfile) require_value "$1" "$#"; PASSFILE=$2; shift 2 ;;
      --service-file) require_value "$1" "$#"; SERVICE_FILE=$2; shift 2 ;;
      --service) require_value "$1" "$#"; SERVICE=$2; shift 2 ;;
      --dump-option) require_value "$1" "$#"; if [[ $CLI_DUMP_OPTIONS_SET == false ]]; then DUMP_OPTIONS=(); CLI_DUMP_OPTIONS_SET=true; fi; validate_dump_option "$2"; DUMP_OPTIONS+=("$2"); shift 2 ;;
      --default-dump-options) DUMP_OPTIONS=(); CLI_DUMP_OPTIONS_SET=true; shift ;;
      --format) require_value "$1" "$#"; FORMAT=$2; shift 2 ;;
      --output-mode) require_value "$1" "$#"; OUTPUT_MODE=$2; shift 2 ;;
      --compression) require_value "$1" "$#"; COMPRESSION=$2; COMPRESSION_EXPLICIT=true; shift 2 ;;
      --globals-mode) require_value "$1" "$#"; GLOBALS_MODE=$2; shift 2 ;;
      --include-role-passwords) INCLUDE_ROLE_PASSWORDS=true; shift ;;
      --no-include-role-passwords) INCLUDE_ROLE_PASSWORDS=false; shift ;;
      --expire-hours) require_value "$1" "$#"; EXPIRE_HOURS=$2; shift 2 ;;
      --before-hook) require_value "$1" "$#"; BEFORE_HOOK=$2; shift 2 ;;
      --after-database-hook) require_value "$1" "$#"; AFTER_DATABASE_HOOK=$2; shift 2 ;;
      --after-hook) require_value "$1" "$#"; AFTER_HOOK=$2; shift 2 ;;
      --notify-start) NOTIFY_START=true; shift ;; --no-notify-start) NOTIFY_START=false; shift ;;
      --notify-redact) NOTIFY_REDACT=true; shift ;; --no-notify-redact) NOTIFY_REDACT=false; shift ;;
      --notify-timeout) require_value "$1" "$#"; NOTIFY_TIMEOUT=$2; shift 2 ;;
      --apprise-config) require_value "$1" "$#"; APPRISE_CLI_CONFIG=$2; shift 2 ;;
      --apprise-api-url) require_value "$1" "$#"; APPRISE_API_URL=$2; shift 2 ;;
      --apprise-urls) require_value "$1" "$#"; APPRISE_API_URLS=$2; shift 2 ;;
      --apprise-tags) require_value "$1" "$#"; APPRISE_TAGS=$2; shift 2 ;;
      --bark-server) require_value "$1" "$#"; BARK_SERVER=$2; shift 2 ;;
      --bark-device-key) require_value "$1" "$#"; BARK_DEVICE_KEY=$2; shift 2 ;;
      --bark-sound) require_value "$1" "$#"; BARK_SOUND=$2; shift 2 ;;
      --bark-group) require_value "$1" "$#"; BARK_GROUP=$2; shift 2 ;;
      --ntfy-server) require_value "$1" "$#"; NTFY_SERVER=$2; shift 2 ;;
      --ntfy-topic) require_value "$1" "$#"; NTFY_TOPIC=$2; shift 2 ;;
      --ntfy-token) require_value "$1" "$#"; NTFY_TOKEN=$2; shift 2 ;;
      --feishu-webhook-url) require_value "$1" "$#"; FEISHU_WEBHOOK_URL=$2; shift 2 ;;
      --wecom-webhook-url) require_value "$1" "$#"; WECOM_WEBHOOK_URL=$2; shift 2 ;;
      --dingtalk-webhook-url) require_value "$1" "$#"; DINGTALK_WEBHOOK_URL=$2; shift 2 ;;
      --gotify-server) require_value "$1" "$#"; GOTIFY_SERVER=$2; shift 2 ;;
      --gotify-token) require_value "$1" "$#"; GOTIFY_TOKEN=$2; shift 2 ;;
      --gotify-priority) require_value "$1" "$#"; GOTIFY_PRIORITY=$2; shift 2 ;;
      --server-name) require_value "$1" "$#"; SERVER_NAME=$2; shift 2 ;;
      --check) CHECK_ONLY=true; shift ;; --no-check) CHECK_ONLY=false; shift ;;
      --dependency-report) DEPENDENCY_REPORT=true; shift ;;
      -h|--help) usage; exit 0 ;; -V|--version) printf '%s %s\n' "$PROGRAM_NAME" "$SCRIPT_VERSION"; exit 0 ;;
      --) shift; [[ $# -eq 0 ]] || die "Positional arguments are not supported" ;;
      --password=*|--apprise-api-url=*|--apprise-urls=*|--bark-device-key=*|--ntfy-token=*|--feishu-webhook-url=*|--wecom-webhook-url=*|--dingtalk-webhook-url=*|--gotify-token=*) die "Inline secret forms are not supported" ;;
      --*=*) die "Inline option forms are not supported: ${1%%=*}" ;;
      *) die "Unknown option: $1" ;;
    esac
  done
  DATABASE_COUNT=${#DATABASES[@]}
}

command_exists() {
  command -v "$1" >/dev/null 2>&1
}

http_notifications_requested() {
  [[ -n $APPRISE_API_URL || -n $APPRISE_API_URLS || -n $BARK_SERVER || -n $BARK_DEVICE_KEY || -n $NTFY_SERVER || -n $NTFY_TOPIC || -n $FEISHU_WEBHOOK_URL || -n $WECOM_WEBHOOK_URL || -n $DINGTALK_WEBHOOK_URL || -n $GOTIFY_SERVER || -n $GOTIFY_TOKEN ]]
}

dependency_command_path() {
  local candidate

  for candidate in "$@"; do
    if command_exists "$candidate"; then
      command -v "$candidate"
      return 0
    fi
  done
  return 1
}

record_missing_dependency() {
  MISSING_DEPENDENCIES+=("$1")
  MISSING_DEPENDENCY_KINDS+=("$2")
}

inspect_dependency() {
  local label=$1
  local kind=$2
  local needed=$3
  local skip_reason=$4
  local verbose=$5
  local path
  shift 5

  if [[ $needed != true ]]; then
    [[ $verbose == true ]] && printf '[SKIP] %s: %s\n' "$label" "$skip_reason"
    return 0
  fi
  if path=$(dependency_command_path "$@"); then
    [[ $verbose == true ]] && printf '[OK] %s: %s\n' "$label" "$path"
    return 0
  fi

  [[ $verbose == true ]] && printf '[MISSING] %s\n' "$label"
  record_missing_dependency "$label" "$kind"
}

inspect_dependencies() {
  local verbose=${1:-false}
  local zip_needed=false
  local curl_needed=false
  local apprise_needed=false

  MISSING_DEPENDENCIES=()
  MISSING_DEPENDENCY_KINDS=()

  [[ $OUTPUT_MODE == zip || $OUTPUT_MODE == both ]] && zip_needed=true
  http_notifications_requested && curl_needed=true
  [[ -n $APPRISE_CLI_CONFIG ]] && apprise_needed=true

  inspect_dependency "pg_dump" postgres true "" "$verbose" pg_dump
  inspect_dependency "pg_dumpall" postgres true "" "$verbose" pg_dumpall
  inspect_dependency "pg_restore" postgres true "" "$verbose" pg_restore
  inspect_dependency "psql" postgres true "" "$verbose" psql
  inspect_dependency "flock" flock true "" "$verbose" flock
  inspect_dependency "sha256sum or shasum" checksum true "" "$verbose" sha256sum shasum
  inspect_dependency "zip" zip "$zip_needed" "output mode is $OUTPUT_MODE" "$verbose" zip
  inspect_dependency "curl" curl "$curl_needed" "no HTTP notification channel is configured" "$verbose" curl
  inspect_dependency "apprise" apprise "$apprise_needed" "Apprise CLI is not configured" "$verbose" apprise
  inspect_dependency "timeout or gtimeout" timeout "$apprise_needed" "Apprise CLI is not configured" "$verbose" timeout gtimeout
}

strip_os_release_quotes() {
  local value=$1

  if [[ ${#value} -ge 2 && ( ( ${value:0:1} == '"' && ${value: -1} == '"' ) || ( ${value:0:1} == "'" && ${value: -1} == "'" ) ) ]]; then
    value=${value:1:${#value}-2}
  fi
  printf '%s\n' "$value"
}

detect_system() {
  local key
  local value
  local uname_value

  SYSTEM_ID=
  SYSTEM_NAME=
  if [[ -r /etc/os-release ]]; then
    while IFS='=' read -r key value; do
      case "$key" in
        ID) SYSTEM_ID=$(strip_os_release_quotes "$value") ;;
        PRETTY_NAME) SYSTEM_NAME=$(strip_os_release_quotes "$value") ;;
      esac
    done < /etc/os-release
  fi
  if [[ -z $SYSTEM_NAME ]] && command_exists uname; then
    uname_value=$(uname -s 2>/dev/null || true)
    case "$uname_value" in
      Darwin)
        SYSTEM_ID=macos
        SYSTEM_NAME=macOS
        ;;
      Linux)
        SYSTEM_ID=${SYSTEM_ID:-linux}
        SYSTEM_NAME=Linux
        ;;
      *) SYSTEM_NAME=${uname_value:-Unknown} ;;
    esac
  fi
  SYSTEM_ID=${SYSTEM_ID:-unknown}
  SYSTEM_NAME=${SYSTEM_NAME:-Unknown}
}

detect_package_manager() {
  local manager

  PACKAGE_MANAGER=
  for manager in apt-get dnf yum apk zypper pacman brew; do
    if command_exists "$manager"; then
      PACKAGE_MANAGER=$manager
      return 0
    fi
  done
  return 1
}

dependency_package_name() {
  local kind=$1

  case "$PACKAGE_MANAGER:$kind" in
    apt-get:postgres) printf 'postgresql-client\n' ;;
    dnf:postgres|yum:postgres) printf 'postgresql\n' ;;
    apk:postgres) printf 'postgresql-client\n' ;;
    zypper:postgres|pacman:postgres) printf 'postgresql\n' ;;
    brew:postgres) printf 'libpq\n' ;;
    *:flock) printf 'util-linux\n' ;;
    *:checksum|*:timeout) printf 'coreutils\n' ;;
    *:zip) printf 'zip\n' ;;
    *:curl) printf 'curl\n' ;;
    *) return 1 ;;
  esac
}

package_is_listed() {
  local expected=$1
  shift
  local package

  for package in "$@"; do
    [[ $package == "$expected" ]] && return 0
  done
  return 1
}

print_install_guidance() {
  local -a packages=()
  local index
  local kind
  local package

  detect_system
  printf '\nDetected system: %s\n' "$SYSTEM_NAME"
  if ! detect_package_manager; then
    printf 'No supported package manager was found. Install the missing commands with your system package manager.\n'
    return 0
  fi

  for ((index = 0; index < ${#MISSING_DEPENDENCY_KINDS[@]}; index++)); do
    kind=${MISSING_DEPENDENCY_KINDS[$index]}
    if package=$(dependency_package_name "$kind"); then
      package_is_listed "$package" "${packages[@]}" || packages+=("$package")
    fi
  done

  printf 'Detected package manager: %s\n' "$PACKAGE_MANAGER"
  if [[ ${#packages[@]} -gt 0 ]]; then
    printf '\nSuggested install command (review before running):\n'
    case "$PACKAGE_MANAGER" in
      apt-get)
        printf 'sudo apt-get update\n'
        printf 'sudo apt-get install'
        ;;
      dnf) printf 'sudo dnf install' ;;
      yum) printf 'sudo yum install' ;;
      apk) printf 'sudo apk add' ;;
      zypper) printf 'sudo zypper install' ;;
      pacman) printf 'sudo pacman -S' ;;
      brew) printf 'brew install' ;;
    esac
    printf ' %s' "${packages[@]}"
    printf '\n'
  fi
  if package_is_listed apprise "${MISSING_DEPENDENCY_KINDS[@]}"; then
    printf 'Apprise CLI: follow https://github.com/caronc/apprise#installation\n'
  fi
  printf 'The script does not execute these commands automatically.\n'
}

show_dependency_report() {
  printf 'Dependency report:\n\n'
  inspect_dependencies true
  if [[ ${#MISSING_DEPENDENCIES[@]} -eq 0 ]]; then
    printf '\nAll required dependencies are available.\n'
    return 0
  fi
  print_install_guidance
  return 2
}

validate_dependencies() {
  local missing_list
  local IFS=', '

  inspect_dependencies false
  [[ ${#MISSING_DEPENDENCIES[@]} -eq 0 ]] && return 0

  missing_list="${MISSING_DEPENDENCIES[*]}"
  printf 'Missing required dependencies: %s\n' "$missing_list" >&2
  print_install_guidance >&2
  FINAL_MESSAGE="Missing required dependencies: $missing_list"
  return 1
}

file_mode() {
  local path=$1

  stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path" 2>/dev/null
}

file_owner() {
  local path=$1

  stat -c '%u' "$path" 2>/dev/null || stat -f '%u' "$path" 2>/dev/null
}

file_size() {
  local path=$1

  stat -c '%s' "$path" 2>/dev/null || stat -f '%z' "$path" 2>/dev/null
}

format_bytes() {
  local bytes=$1
  local divisor=1
  local unit=B
  local whole
  local tenths

  if (( bytes >= 1125899906842624 )); then
    divisor=1125899906842624
    unit=PB
  elif (( bytes >= 1099511627776 )); then
    divisor=1099511627776
    unit=TB
  elif (( bytes >= 1073741824 )); then
    divisor=1073741824
    unit=GB
  elif (( bytes >= 1048576 )); then
    divisor=1048576
    unit=MB
  elif (( bytes >= 1024 )); then
    divisor=1024
    unit=KB
  else
    printf '%s B\n' "$bytes"
    return 0
  fi

  whole=$((bytes / divisor))
  tenths=$((((bytes % divisor) * 10 + divisor / 2) / divisor))
  if (( tenths == 10 )); then
    whole=$((whole + 1))
    tenths=0
  fi
  printf '%s.%s %s\n' "$whole" "$tenths" "$unit"
}

backup_artifacts_size() {
  local artifact
  local artifact_size
  local total=0
  local found=false

  [[ -n $PUBLISHED_DIR && -d $PUBLISHED_DIR ]] || return 1
  while IFS= read -r -d '' artifact; do
    artifact_size=$(file_size "$artifact") || return 1
    [[ $artifact_size =~ ^[0-9]+$ ]] || return 1
    total=$((total + artifact_size))
    found=true
  done < <(find "$PUBLISHED_DIR" -maxdepth 1 -type f \( -name '*.sql' -o -name '*.dump' -o -name '*.zip' \) -print0)
  [[ $found == true ]] || return 1
  format_bytes "$total"
}

normalize_decimal() {
  local value=$1

  while [[ ${#value} -gt 1 && ${value:0:1} == 0 ]]; do
    value=${value:1}
  done
  printf '%s\n' "$value"
}

normalize_signed_decimal() {
  local value=$1
  local sign=

  if [[ $value == -* ]]; then
    sign=-
    value=${value#-}
  fi
  value=$(normalize_decimal "$value")
  if [[ $value == 0 ]]; then
    sign=
  fi
  printf '%s%s\n' "$sign" "$value"
}

validate_secret_file() {
  local label=$1
  local variable_name=$2
  local path=${!variable_name}
  local mode
  local owner
  local current_uid
  local canonical_parent
  local canonical_path

  ! value_has_control_characters "$path" || die "$label path cannot contain control characters"
  [[ ! -L $path ]] || die "$label must not be a symbolic link: $path"
  canonical_parent=$(cd "$(dirname "$path")" && pwd -P) || die "Cannot resolve parent directory for $label: $path"
  canonical_path="$canonical_parent/$(basename "$path")"
  path=$canonical_path
  [[ -f $path && -r $path ]] || die "$label is not a readable file: $path"
  mode=$(file_mode "$path") || die "Cannot read permissions for $label: $path"
  [[ $mode =~ ^[0-7]+$ ]] || die "Invalid permissions for $label: $path"
  (( (8#$mode & 077) == 0 )) || die "$label must not be accessible by group or others: $path"
  owner=$(file_owner "$path") || die "Cannot read owner for $label: $path"
  current_uid=$(id -u) || die "Cannot determine the current user ID"
  [[ $owner == "$current_uid" ]] || die "$label must be owned by the current user: $path"
  if ! check_trusted_directory_chain "$label parent directory" "$canonical_parent" "$current_uid"; then
    die "$FINAL_MESSAGE"
  fi
  printf -v "$variable_name" '%s' "$canonical_path"
}

check_trusted_directory_chain() {
  local label=$1
  local directory=$2
  local current_uid=$3
  local mode
  local owner

  while :; do
    mode=$(file_mode "$directory") || {
      FINAL_MESSAGE="Cannot read $label permissions: $directory"
      return 1
    }
    if [[ ! $mode =~ ^[0-7]+$ ]]; then
      FINAL_MESSAGE="Invalid $label permissions: $directory"
      return 1
    fi
    if (( (8#$mode & 022) != 0 )); then
      FINAL_MESSAGE="$label must not be writable by group or others: $directory"
      return 1
    fi
    owner=$(file_owner "$directory") || {
      FINAL_MESSAGE="Cannot read $label owner: $directory"
      return 1
    }
    if [[ $owner != "$current_uid" && $owner != 0 ]]; then
      FINAL_MESSAGE="$label must be owned by the current user or root: $directory"
      return 1
    fi
    [[ $directory == / ]] && break
    directory=$(dirname "$directory")
  done
}

validate_hook() {
  local label=$1
  local variable_name=$2
  local path=${!variable_name}
  local mode
  local owner
  local current_uid
  local logical_parent
  local canonical_parent
  local canonical_path

  [[ -z $path ]] && return 0
  ! value_has_control_characters "$path" || die "$label path cannot contain control characters"
  [[ ! -L $path ]] || die "$label must not be a symbolic link: $path"
  [[ -f $path && -x $path ]] || die "$label must be an executable file: $path"
  logical_parent=$(cd "$(dirname "$path")" && pwd -L) || die "Cannot resolve parent directory for $label: $path"
  canonical_parent=$(cd "$(dirname "$path")" && pwd -P) || die "Cannot resolve parent directory for $label: $path"
  [[ $logical_parent == "$canonical_parent" ]] || die "$label parent path must not contain symbolic links: $path"
  canonical_path="$canonical_parent/$(basename "$path")"
  mode=$(file_mode "$path") || die "Cannot read permissions for $label: $path"
  [[ $mode =~ ^[0-7]+$ ]] || die "Invalid permissions for $label: $path"
  (( (8#$mode & 022) == 0 )) || die "$label must not be writable by group or others: $path"
  owner=$(file_owner "$path") || die "Cannot read owner for $label: $path"
  current_uid=$(id -u) || die "Cannot determine the current user ID"
  [[ $owner == "$current_uid" ]] || die "$label must be owned by the current user: $path"
  if ! check_trusted_directory_chain "Hook parent directory" "$canonical_parent" "$current_uid"; then
    die "$FINAL_MESSAGE"
  fi
  printf -v "$variable_name" '%s' "$canonical_path"
}

prepare_target_dir() {
  local mode
  local owner
  local current_uid
  local canonical_parent

  [[ -n $TARGET_DIR ]] || die "--target-dir is required"
  ! value_has_control_characters "$TARGET_DIR" || die "Target directory cannot contain control characters"
  [[ -d $TARGET_DIR && ! -L $TARGET_DIR ]] || die "Target directory must already exist and must not be a symbolic link: $TARGET_DIR"
  TARGET_DIR=$(cd "$TARGET_DIR" && pwd -P) || die "Cannot resolve target directory: $TARGET_DIR"
  [[ $TARGET_DIR != / ]] || die "The filesystem root cannot be used as --target-dir"
  [[ -w $TARGET_DIR ]] || die "Target directory is not writable: $TARGET_DIR"
  mode=$(file_mode "$TARGET_DIR") || die "Cannot read target directory permissions: $TARGET_DIR"
  [[ $mode =~ ^[0-7]+$ ]] || die "Invalid target directory permissions: $TARGET_DIR"
  (( (8#$mode & 022) == 0 )) || die "Target directory must not be writable by group or others: $TARGET_DIR"
  owner=$(file_owner "$TARGET_DIR") || die "Cannot read target directory owner: $TARGET_DIR"
  current_uid=$(id -u) || die "Cannot determine the current user ID"
  [[ $owner == "$current_uid" ]] || die "Target directory must be owned by the current user: $TARGET_DIR"
  canonical_parent=$(dirname "$TARGET_DIR")
  if ! check_trusted_directory_chain "Target parent directory" "$canonical_parent" "$current_uid"; then
    die "$FINAL_MESSAGE"
  fi
}

check_private_file_ready() {
  local label=$1
  local variable_name=$2
  local path=${!variable_name}
  local mode
  local owner
  local current_uid
  local canonical_parent
  local canonical_path

  if value_has_control_characters "$path"; then
    FINAL_MESSAGE="$label path cannot contain control characters"
    return 1
  fi
  if [[ -L $path ]]; then
    FINAL_MESSAGE="$label must not be a symbolic link: $path"
    return 1
  fi
  canonical_parent=$(cd "$(dirname "$path")" && pwd -P) || {
    FINAL_MESSAGE="Cannot resolve parent directory for $label: $path"
    return 1
  }
  canonical_path="$canonical_parent/$(basename "$path")"
  path=$canonical_path
  if [[ ! -f $path || ! -r $path ]]; then
    FINAL_MESSAGE="$label is not a readable file: $path"
    return 1
  fi
  mode=$(file_mode "$path") || {
    FINAL_MESSAGE="Cannot read permissions for $label: $path"
    return 1
  }
  if [[ ! $mode =~ ^[0-7]+$ ]] || (( (8#$mode & 077) != 0 )); then
    FINAL_MESSAGE="$label must not be accessible by group or others: $path"
    return 1
  fi
  owner=$(file_owner "$path") || {
    FINAL_MESSAGE="Cannot read owner for $label: $path"
    return 1
  }
  current_uid=$(id -u) || {
    FINAL_MESSAGE="Cannot determine the current user ID"
    return 1
  }
  if [[ $owner != "$current_uid" ]]; then
    FINAL_MESSAGE="$label must be owned by the current user: $path"
    return 1
  fi
  if ! check_trusted_directory_chain "$label parent directory" "$canonical_parent" "$current_uid"; then
    return 1
  fi
  printf -v "$variable_name" '%s' "$canonical_path"
}

notification_config_problem() {
  local channel=$1
  shift
  local message=$1
  shift
  local variable_name

  if [[ $CHECK_ONLY == true ]]; then
    die "$channel notification configuration is invalid: $message"
  fi
  log WARN "$channel notification disabled: $message" >&2
  for variable_name in "$@"; do
    printf -v "$variable_name" '%s' ''
  done
}

url_is_http() {
  local url=$1
  local authority

  ! value_has_control_characters "$url" || return 1
  [[ $url != *[[:space:]]* ]] || return 1
  if [[ $url == https://* ]]; then
    authority=${url#https://}
    authority=${authority%%[/?#]*}
    [[ -n $authority ]]
    return
  fi
  [[ $url == http://* ]] || return 1

  authority=${url#http://}
  authority=${authority%%[/?#]*}
  [[ $authority != *@* ]] || return 1
  [[ $authority =~ ^(localhost|127\.0\.0\.1)(:[0-9]{1,5})?$ || $authority =~ ^\[::1\](:[0-9]{1,5})?$ ]]
}

value_has_control_characters() {
  local value=$1
  local LC_ALL=C

  [[ $value =~ [[:cntrl:]] ]]
}

validate_http_channel_url() {
  local channel=$1
  local variable_name=$2
  local url=${!variable_name}

  [[ -z $url ]] && return 0
  if ! url_is_http "$url"; then
    notification_config_problem "$channel" "$variable_name must use https://; http:// is allowed only for loopback addresses" "$variable_name"
  fi
}

configure_notifications() {
  local gotify_magnitude
  local http_channel_count=0
  local notification_requested=false

  if [[ -n $APPRISE_CLI_CONFIG || -n $APPRISE_API_URL || -n $APPRISE_API_URLS || -n $APPRISE_TAGS || -n $BARK_SERVER || -n $BARK_DEVICE_KEY || -n $BARK_SOUND || -n $BARK_GROUP || -n $NTFY_SERVER || -n $NTFY_TOPIC || -n $NTFY_TOKEN || -n $FEISHU_WEBHOOK_URL || -n $WECOM_WEBHOOK_URL || -n $DINGTALK_WEBHOOK_URL || -n $GOTIFY_SERVER || -n $GOTIFY_TOKEN ]]; then
    notification_requested=true
  fi
  [[ $notification_requested == true ]] || return 0

  if [[ ! $NOTIFY_TIMEOUT =~ ^[0-9]+$ ]]; then
    if [[ $CHECK_ONLY == true ]]; then
      die "Notification timeout (--notify-timeout / PGSQL_BACKUP_NOTIFY_TIMEOUT) must be an integer between 1 and 300"
    fi
    log WARN "Invalid notification timeout; using 10 seconds" >&2
    NOTIFY_TIMEOUT=10
  else
    NOTIFY_TIMEOUT=$(normalize_decimal "$NOTIFY_TIMEOUT")
    if [[ ${#NOTIFY_TIMEOUT} -gt 3 ]] || (( NOTIFY_TIMEOUT < 1 || NOTIFY_TIMEOUT > 300 )); then
      if [[ $CHECK_ONLY == true ]]; then
        die "Notification timeout (--notify-timeout / PGSQL_BACKUP_NOTIFY_TIMEOUT) must be between 1 and 300"
      fi
      log WARN "Invalid notification timeout; using 10 seconds" >&2
      NOTIFY_TIMEOUT=10
    fi
  fi

  if [[ -n $APPRISE_CLI_CONFIG ]] && value_has_control_characters "$APPRISE_CLI_CONFIG"; then
    notification_config_problem "Apprise CLI" "configuration path contains control characters" APPRISE_CLI_CONFIG
  fi
  if [[ -n $APPRISE_CLI_CONFIG ]]; then
    if ! check_private_file_ready "Apprise CLI configuration" APPRISE_CLI_CONFIG; then
      notification_config_problem "Apprise CLI" "$FINAL_MESSAGE" APPRISE_CLI_CONFIG
      FINAL_MESSAGE=
    elif ! command_exists apprise; then
      if [[ $CHECK_ONLY == false ]]; then
        notification_config_problem "Apprise CLI" "apprise was not found in PATH" APPRISE_CLI_CONFIG
      fi
    elif command_exists timeout; then
      TIMEOUT_COMMAND=timeout
    elif command_exists gtimeout; then
      TIMEOUT_COMMAND=gtimeout
    elif [[ $CHECK_ONLY == false ]]; then
      notification_config_problem "Apprise CLI" "timeout or gtimeout was not found in PATH" APPRISE_CLI_CONFIG
    fi
  fi

  if [[ -n $APPRISE_API_URLS && -z $APPRISE_API_URL ]]; then
    notification_config_problem "Apprise API" "APPRISE_API_URLS requires APPRISE_API_URL" APPRISE_API_URLS
  fi
  if value_has_control_characters "$APPRISE_API_URLS"; then
    notification_config_problem "Apprise API" "service URLs contain control characters" APPRISE_API_URL APPRISE_API_URLS
  fi
  if value_has_control_characters "$APPRISE_TAGS"; then
    notification_config_problem "Apprise" "tags contain control characters" APPRISE_TAGS
  fi
  if [[ -n $BARK_SERVER || -n $BARK_DEVICE_KEY ]]; then
    if [[ -z $BARK_SERVER || -z $BARK_DEVICE_KEY ]]; then
      notification_config_problem "Bark" "BARK_SERVER and BARK_DEVICE_KEY are both required" BARK_SERVER BARK_DEVICE_KEY
    fi
  fi
  if value_has_control_characters "$BARK_DEVICE_KEY$BARK_SOUND$BARK_GROUP"; then
    notification_config_problem "Bark" "Bark device key, sound, or group contains control characters" BARK_SERVER BARK_DEVICE_KEY BARK_SOUND BARK_GROUP
  fi
  if [[ -n $NTFY_SERVER || -n $NTFY_TOPIC ]]; then
    if [[ -z $NTFY_SERVER || -z $NTFY_TOPIC ]]; then
      notification_config_problem "ntfy" "NTFY_SERVER and NTFY_TOPIC are both required" NTFY_SERVER NTFY_TOPIC
    fi
  fi
  if value_has_control_characters "$NTFY_TOPIC$NTFY_TOKEN"; then
    notification_config_problem "ntfy" "ntfy topic or token contains control characters" NTFY_SERVER NTFY_TOPIC
  fi
  if [[ -n $GOTIFY_SERVER || -n $GOTIFY_TOKEN ]]; then
    if [[ -z $GOTIFY_SERVER || -z $GOTIFY_TOKEN ]]; then
      notification_config_problem "Gotify" "GOTIFY_SERVER and GOTIFY_TOKEN are both required" GOTIFY_SERVER GOTIFY_TOKEN
    elif value_has_control_characters "$GOTIFY_TOKEN"; then
      notification_config_problem "Gotify" "Gotify token contains control characters" GOTIFY_SERVER GOTIFY_TOKEN
    elif [[ ! $GOTIFY_PRIORITY =~ ^-?[0-9]+$ ]]; then
      notification_config_problem "Gotify" "priority must be an integer between -9999 and 9999" GOTIFY_SERVER GOTIFY_TOKEN
    else
      GOTIFY_PRIORITY=$(normalize_signed_decimal "$GOTIFY_PRIORITY")
      gotify_magnitude=${GOTIFY_PRIORITY#-}
      if [[ ${#gotify_magnitude} -gt 4 ]]; then
        notification_config_problem "Gotify" "priority must be between -9999 and 9999" GOTIFY_SERVER GOTIFY_TOKEN
      fi
    fi
  fi

  validate_http_channel_url "Apprise API" APPRISE_API_URL
  validate_http_channel_url "Bark" BARK_SERVER
  validate_http_channel_url "ntfy" NTFY_SERVER
  validate_http_channel_url "Feishu" FEISHU_WEBHOOK_URL
  validate_http_channel_url "WeCom" WECOM_WEBHOOK_URL
  validate_http_channel_url "DingTalk" DINGTALK_WEBHOOK_URL
  validate_http_channel_url "Gotify" GOTIFY_SERVER

  [[ -n $APPRISE_API_URL ]] && http_channel_count=$((http_channel_count + 1))
  [[ -n $BARK_SERVER ]] && http_channel_count=$((http_channel_count + 1))
  [[ -n $NTFY_SERVER ]] && http_channel_count=$((http_channel_count + 1))
  [[ -n $FEISHU_WEBHOOK_URL ]] && http_channel_count=$((http_channel_count + 1))
  [[ -n $WECOM_WEBHOOK_URL ]] && http_channel_count=$((http_channel_count + 1))
  [[ -n $DINGTALK_WEBHOOK_URL ]] && http_channel_count=$((http_channel_count + 1))
  [[ -n $GOTIFY_SERVER ]] && http_channel_count=$((http_channel_count + 1))

  if [[ $http_channel_count -gt 0 ]] && ! command_exists curl; then
    if [[ $CHECK_ONLY == false ]]; then
      notification_config_problem "HTTP" "curl was not found in PATH" APPRISE_API_URL BARK_SERVER NTFY_SERVER FEISHU_WEBHOOK_URL WECOM_WEBHOOK_URL DINGTALK_WEBHOOK_URL GOTIFY_SERVER
      http_channel_count=0
    fi
  fi

  NOTIFICATION_NAMES=()
  NOTIFICATION_SENDERS=()
  NOTIFICATION_VALIDATORS=()
  [[ -n $APPRISE_CLI_CONFIG ]] && register_notification_channel "Apprise CLI" send_apprise_cli_notification notification_response_accepted
  [[ -n $APPRISE_API_URL ]] && register_notification_channel "Apprise API" send_apprise_api_notification validate_apprise_api_response
  [[ -n $BARK_SERVER ]] && register_notification_channel "Bark" send_bark_notification validate_bark_response
  [[ -n $NTFY_SERVER ]] && register_notification_channel "ntfy" send_ntfy_notification notification_response_accepted
  [[ -n $FEISHU_WEBHOOK_URL ]] && register_notification_channel "Feishu" send_feishu_notification validate_feishu_response
  [[ -n $WECOM_WEBHOOK_URL ]] && register_notification_channel "WeCom" send_wecom_notification validate_errcode_response
  [[ -n $DINGTALK_WEBHOOK_URL ]] && register_notification_channel "DingTalk" send_dingtalk_notification validate_errcode_response
  [[ -n $GOTIFY_SERVER ]] && register_notification_channel "Gotify" send_gotify_notification notification_response_accepted
  NOTIFICATION_CHANNEL_COUNT=${#NOTIFICATION_NAMES[@]}
  if [[ $NOTIFICATION_CHANNEL_COUNT -eq 0 ]]; then
    if [[ $CHECK_ONLY == true ]]; then
      die "Notification configuration does not enable a complete channel"
    fi
    log WARN "Notification configuration does not enable a complete channel" >&2
  fi
}

register_notification_channel() {
  NOTIFICATION_NAMES+=("$1")
  NOTIFICATION_SENDERS+=("$2")
  NOTIFICATION_VALIDATORS+=("$3")
}

validate_config() {
  local database dump_option seen="\n"
  [[ $NOTIFY_REDACT == true || $NOTIFY_REDACT == false ]] || die "Notification redaction setting must be true or false"
  [[ $NOTIFY_START == true || $NOTIFY_START == false ]] || die "Notify-start setting must be true or false"
  [[ $CHECK_ONLY == true || $CHECK_ONLY == false ]] || die "Check setting must be true or false"
  [[ $INCLUDE_ROLE_PASSWORDS == true || $INCLUDE_ROLE_PASSWORDS == false ]] || die "Role-password setting must be true or false"
  case "$FORMAT" in custom|plain) ;; *) die "--format must be custom or plain" ;; esac
  case "$OUTPUT_MODE" in zip|files|both) ;; *) die "--output-mode must be zip, files, or both" ;; esac
  case "$GLOBALS_MODE" in auto|include|skip) ;; *) die "--globals-mode must be auto, include, or skip" ;; esac
  [[ $COMPRESSION =~ ^[0-9]$ ]] || die "--compression must be between 0 and 9"
  [[ $FORMAT == custom || $COMPRESSION_EXPLICIT == false ]] || die "--compression only applies to custom format"
  [[ ! ( $GLOBALS_MODE == skip && $INCLUDE_ROLE_PASSWORDS == true ) ]] || die "Role passwords cannot be included when globals are skipped"
  [[ -z $SERVICE_FILE || -n $SERVICE ]] || die "--service-file requires --service"
  [[ ! ( $PASSWORD_CONFIGURED == true && -n $PASSFILE ) ]] || die "Direct password and passfile are mutually exclusive"
  [[ ! ( $PASSWORD_CONFIGURED == true && -n $SERVICE ) ]] || die "Direct password cannot be combined with service; use a passfile"

  CURRENT_PHASE=validation
  configure_notifications
  if [[ $CHECK_ONLY == false && $NOTIFICATION_CHANNEL_COUNT -gt 0 ]]; then
    trap on_exit EXIT; trap 'on_signal INT' INT; trap 'on_signal TERM' TERM; NOTIFY_ON_EXIT=true
  fi
  prepare_target_dir
  for database in "$DB_HOST" "$DB_USER" "$MAINTENANCE_DATABASE" "$SERVICE" "$SERVER_NAME"; do
    ! value_has_control_characters "$database" || die "Configuration values cannot contain control characters"
  done
  [[ -n $MAINTENANCE_DATABASE ]] || die "--maintenance-database cannot be empty"
  [[ $DB_PORT =~ ^[0-9]+$ ]] || die "--port must be between 1 and 65535"
  DB_PORT=$(normalize_decimal "$DB_PORT")
  (( DB_PORT >= 1 && DB_PORT <= 65535 )) || die "--port must be between 1 and 65535"
  [[ $EXPIRE_HOURS =~ ^[0-9]+$ ]] || die "--expire-hours must be a non-negative integer"
  EXPIRE_HOURS=$(normalize_decimal "$EXPIRE_HOURS")
  (( EXPIRE_HOURS <= 8760000 )) || die "--expire-hours must not exceed 8760000"
  if [[ $PASSWORD_CONFIGURED == true ]]; then
    ! value_has_control_characters "$DB_PASSWORD" || die "Database password cannot contain control characters"
  fi
  for database in "${DATABASES[@]}"; do
    [[ -n $database ]] || die "Database names cannot be empty"
    ! value_has_control_characters "$database" || die "Database names cannot contain control characters"
    [[ $seen != *$'\n'"$database"$'\n'* ]] || die "Duplicate database: $database"
    seen+="$database"$'\n'
  done
  for dump_option in "${DUMP_OPTIONS[@]}"; do validate_dump_option "$dump_option"; done
  [[ -z $PASSFILE ]] || validate_secret_file "PostgreSQL passfile" PASSFILE
  [[ -z $SERVICE_FILE ]] || validate_secret_file "PostgreSQL service file" SERVICE_FILE
  validate_hook "Before hook" BEFORE_HOOK
  validate_hook "After database hook" AFTER_DATABASE_HOOK
  validate_hook "After hook" AFTER_HOOK
  validate_dependencies || exit 2

  if [[ -n $SERVICE ]]; then CONNECTION_SOURCE=service; fi
  if [[ $PASSWORD_CONFIGURED == true ]]; then CREDENTIAL_SOURCE=password
  elif [[ -n $PASSFILE && -n $SERVICE ]]; then CREDENTIAL_SOURCE=service+passfile
  elif [[ -n $PASSFILE ]]; then CREDENTIAL_SOURCE=passfile
  elif [[ -n $SERVICE ]]; then CREDENTIAL_SOURCE=service
  else CREDENTIAL_SOURCE=default
  fi
}

cleanup_stage() {
  [[ -n $STAGE_DIR && -d $STAGE_DIR ]] || return 0

  case "$STAGE_DIR" in
    "$TARGET_DIR"/.staging/*)
      rm -rf -- "$STAGE_DIR" || {
        log ERROR "Cannot remove staging directory: $STAGE_DIR" >&2
        return 1
      }
      ;;
    *)
      log ERROR "Refusing to remove unexpected staging path: $STAGE_DIR" >&2
      return 1
      ;;
  esac
}

cleanup_error_log() {
  [[ -n $ERROR_LOG && -e $ERROR_LOG ]] || return 0

  case "$ERROR_LOG" in
    "$TARGET_DIR"/.staging/pgback_*.error.log)
      rm -f -- "$ERROR_LOG" || {
        log ERROR "Cannot remove runtime error log: $ERROR_LOG" >&2
        return 1
      }
      ;;
    *)
      log ERROR "Refusing to remove unexpected error log path: $ERROR_LOG" >&2
      return 1
      ;;
  esac
}

database_summary() {
  local IFS=,
  if [[ $NOTIFY_REDACT == true ]]; then printf '[redacted]\n'; else printf '%s\n' "${DATABASES[*]:-ALL}"; fi
}

connection_value() {
  if [[ $CONNECTION_SOURCE == service && $1 != true ]]; then printf 'profile-managed\n'; else printf '%s\n' "$2"; fi
}

database_connection_summary() {
  if [[ $NOTIFY_REDACT == true ]]; then
    printf 'source=%s; host=[redacted]; port=[redacted]; user=[redacted]\n' "$CONNECTION_SOURCE"
  else
    printf 'source=%s; host=%s; port=%s; user=%s\n' "$CONNECTION_SOURCE" "$(connection_value "$DB_HOST_EXPLICIT" "$DB_HOST")" "$(connection_value "$DB_PORT_EXPLICIT" "$DB_PORT")" "$(connection_value "$DB_USER_EXPLICIT" "$DB_USER")"
  fi
}

json_escape() {
  local value=$1

  value=$(LC_ALL=C printf '%s' "$value" | tr -d '\000-\010\013\014\016-\037')
  value=${value//\\/\\\\}
  value=${value//\"/\\\"}
  value=${value//$'\n'/\\n}
  value=${value//$'\r'/\\r}
  value=${value//$'\t'/\\t}
  printf '%s' "$value"
}

strip_trailing_slashes() {
  local value=$1

  while [[ $value == */ ]]; do
    value=${value%/}
  done
  printf '%s\n' "$value"
}

curl_config_escape() {
  local value=$1

  value=${value//\\/\\\\}
  value=${value//\"/\\\"}
  printf '%s' "$value"
}

notification_response_accepted() {
  return 0
}

validate_apprise_api_response() {
  local http_code=$1

  [[ $http_code == 200 ]]
}

validate_bark_response() {
  local response=$2

  [[ $response =~ \"code\"[[:space:]]*:[[:space:]]*200 ]]
}

validate_feishu_response() {
  local response=$2

  [[ $response =~ \"StatusCode\"[[:space:]]*:[[:space:]]*0 || $response =~ \"code\"[[:space:]]*:[[:space:]]*0 ]]
}

validate_errcode_response() {
  local response=$2

  [[ $response =~ \"errcode\"[[:space:]]*:[[:space:]]*0 ]]
}

post_notification_json() {
  local channel=$1
  local url=$2
  local payload=$3
  local secret_header=$4
  local response_validator=$5
  local curl_config
  local output
  local response
  local http_code

  curl_config=$(printf 'url = "%s"\n' "$(curl_config_escape "$url")")
  if [[ -n $secret_header ]]; then
    curl_config+=$'\n'
    curl_config+=$(printf 'header = "%s"\n' "$(curl_config_escape "$secret_header")")
  fi

  if ! output=$(curl \
    --disable \
    --silent \
    --connect-timeout 5 \
    --max-time "$NOTIFY_TIMEOUT" \
    --max-filesize 65536 \
    --request POST \
    --header 'Content-Type: application/json' \
    --config /dev/fd/3 \
    --data-binary @- \
    --write-out $'\n%{http_code}' \
    3<<<"$curl_config" \
    <<<"$payload" \
    2>/dev/null); then
    log WARN "$channel notification request failed" >&2
    return 1
  fi

  http_code=${output##*$'\n'}
  response=${output%$'\n'*}
  if [[ ! $http_code =~ ^2[0-9][0-9]$ ]]; then
    log WARN "$channel notification returned HTTP $http_code" >&2
    return 1
  fi
  if ! "$response_validator" "$http_code" "$response"; then
    log WARN "$channel notification service rejected the request (HTTP $http_code)" >&2
    return 1
  fi
  return 0
}

send_apprise_cli_notification() {
  shift
  local type=$1
  local title=$2
  local body=$3

  if [[ -n $APPRISE_TAGS ]]; then
    "$TIMEOUT_COMMAND" --signal=TERM --kill-after=2 "$NOTIFY_TIMEOUT" apprise \
      -c "$APPRISE_CLI_CONFIG" \
      -n "$type" \
      -t "$title" \
      -g "$APPRISE_TAGS" <<<"$body" >/dev/null 2>&1
  else
    "$TIMEOUT_COMMAND" --signal=TERM --kill-after=2 "$NOTIFY_TIMEOUT" apprise \
      -c "$APPRISE_CLI_CONFIG" \
      -n "$type" \
      -t "$title" <<<"$body" >/dev/null 2>&1
  fi
}

send_apprise_api_notification() {
  local response_validator=$1
  local type=$2
  local title=$3
  local body=$4
  local payload

  payload=$(printf '{"body":"%s","title":"%s","type":"%s","format":"text"' \
    "$(json_escape "$body")" \
    "$(json_escape "$title")" \
    "$(json_escape "$type")")
  if [[ -n $APPRISE_API_URLS ]]; then
    payload+=",\"urls\":\"$(json_escape "$APPRISE_API_URLS")\""
  fi
  if [[ -n $APPRISE_TAGS ]]; then
    payload+=",\"tag\":\"$(json_escape "$APPRISE_TAGS")\""
  fi
  payload+='}'
  post_notification_json "Apprise API" "$APPRISE_API_URL" "$payload" "" "$response_validator"
}

send_bark_notification() {
  local response_validator=$1
  local _type=$2
  local title=$3
  local body=$4
  local server
  local payload

  server=$(strip_trailing_slashes "$BARK_SERVER")
  payload=$(printf '{"title":"%s","body":"%s"' \
    "$(json_escape "$title")" \
    "$(json_escape "$body")")
  if [[ -n $BARK_SOUND ]]; then
    payload+=",\"sound\":\"$(json_escape "$BARK_SOUND")\""
  fi
  if [[ -n $BARK_GROUP ]]; then
    payload+=",\"group\":\"$(json_escape "$BARK_GROUP")\""
  fi
  payload+='}'
  post_notification_json "Bark" "$server/$BARK_DEVICE_KEY" "$payload" "" "$response_validator"
}

send_ntfy_notification() {
  local response_validator=$1
  local _type=$2
  local title=$3
  local body=$4
  local server
  local payload

  server=$(strip_trailing_slashes "$NTFY_SERVER")
  payload=$(printf '{"topic":"%s","title":"%s","message":"%s"}' \
    "$(json_escape "$NTFY_TOPIC")" \
    "$(json_escape "$title")" \
    "$(json_escape "$body")")
  if [[ -n $NTFY_TOKEN ]]; then
    post_notification_json "ntfy" "$server" "$payload" \
      "Authorization: Bearer $NTFY_TOKEN" "$response_validator"
  else
    post_notification_json "ntfy" "$server" "$payload" "" "$response_validator"
  fi
}

send_feishu_notification() {
  local response_validator=$1
  local _type=$2
  local title=$3
  local body=$4
  local content
  local payload

  content=$(printf '%s\n\n%s' "$title" "$body")
  payload=$(printf '{"msg_type":"text","content":{"text":"%s"}}' \
    "$(json_escape "$content")")
  post_notification_json "Feishu" "$FEISHU_WEBHOOK_URL" "$payload" "" "$response_validator"
}

send_wecom_notification() {
  local response_validator=$1
  local _type=$2
  local title=$3
  local body=$4
  local content
  local payload

  content=$(printf '%s\n\n%s' "$title" "$body")
  payload=$(printf '{"msgtype":"text","text":{"content":"%s"}}' \
    "$(json_escape "$content")")
  post_notification_json "WeCom" "$WECOM_WEBHOOK_URL" "$payload" "" "$response_validator"
}

send_dingtalk_notification() {
  local response_validator=$1
  local _type=$2
  local title=$3
  local body=$4
  local content
  local payload

  content=$(printf '%s\n\n%s' "$title" "$body")
  payload=$(printf '{"msgtype":"text","text":{"content":"%s"}}' \
    "$(json_escape "$content")")
  post_notification_json "DingTalk" "$DINGTALK_WEBHOOK_URL" "$payload" "" "$response_validator"
}

send_gotify_notification() {
  local response_validator=$1
  local _type=$2
  local title=$3
  local body=$4
  local server
  local payload

  server=$(strip_trailing_slashes "$GOTIFY_SERVER")
  payload=$(printf '{"title":"%s","message":"%s","priority":%s}' \
    "$(json_escape "$title")" \
    "$(json_escape "$body")" \
    "$GOTIFY_PRIORITY")
  post_notification_json "Gotify" "$server/message" "$payload" \
    "X-Gotify-Key: $GOTIFY_TOKEN" "$response_validator"
}

send_notification_channel() {
  local channel=$1
  local sender=$2
  local response_validator=$3
  shift 3

  if "$sender" "$response_validator" "$@"; then
    log INFO "$channel notification sent"
    return 0
  fi
  log WARN "$channel notification failed" >&2
  return 1
}

notify_event() {
  local event=$1
  local message=$2
  local type
  local title
  local body
  local backup_size
  local notification_message=$message
  local output_value=${PUBLISHED_DIR:-not-published}
  local elapsed=0
  local success_count=0
  local index
  local pid
  local -a notification_pids=()

  [[ $NOTIFICATION_CHANNEL_COUNT -gt 0 ]] || return 0

  case "$event" in
    start) type=info ;;
    success) type=success ;;
    failure) type=failure ;;
    *)
      log WARN "Skipping unknown notification event: $event"
      return 0
      ;;
  esac

  if [[ $NOTIFY_REDACT == true ]]; then
    [[ -z $PUBLISHED_DIR ]] || output_value='[redacted]'
    case "$event" in
      start) notification_message="Backup started" ;;
      success) notification_message="Backup completed" ;;
      failure) notification_message="Backup failed; check local logs" ;;
    esac
  fi

  if [[ $START_EPOCH -gt 0 ]]; then
    elapsed=$(($(date +%s) - START_EPOCH))
  fi

  title="[$SERVER_NAME] PostgreSQL backup $event"
  body=$(printf '%s\n' \
    "Result: $event" \
    "Server: $SERVER_NAME" \
    "Database connection: $(database_connection_summary)" \
    "Databases: $(database_summary)" \
    "Run ID: ${RUN_ID:-not-created}" \
    "Phase: $CURRENT_PHASE" \
    "Output: $output_value" \
    "Elapsed: ${elapsed}s" \
    "Message: $notification_message")
  if backup_size=$(backup_artifacts_size); then
    body=$(printf '%s\nBackup size: %s' "$body" "$backup_size")
  fi

  for ((index = 0; index < NOTIFICATION_CHANNEL_COUNT; index++)); do
    send_notification_channel \
      "${NOTIFICATION_NAMES[$index]}" \
      "${NOTIFICATION_SENDERS[$index]}" \
      "${NOTIFICATION_VALIDATORS[$index]}" \
      "$type" \
      "$title" \
      "$body" &
    notification_pids+=("$!")
  done

  for pid in "${notification_pids[@]}"; do
    if wait "$pid"; then
      success_count=$((success_count + 1))
    fi
  done

  log INFO "Notifications sent: $success_count/$NOTIFICATION_CHANNEL_COUNT channel(s)"
  [[ $success_count -gt 0 ]]
}

on_exit() {
  local status=$?
  local event
  local message

  trap - EXIT INT TERM
  set +e
  exec 8<&-
  cleanup_stage
  cleanup_error_log
  clear_secrets
  exec 9<&-

  if [[ $NOTIFY_ON_EXIT == true ]]; then
    if [[ $status -eq 0 ]]; then
      event=success
      message=${FINAL_MESSAGE:-Backup completed}
    else
      event=failure
      message=${FINAL_MESSAGE:-Backup command failed during $CURRENT_PHASE}
    fi
    notify_event "$event" "$message"
  fi

  exit "$status"
}

on_signal() {
  local signal=$1

  FINAL_MESSAGE="Backup interrupted by $signal during $CURRENT_PHASE"
  log ERROR "Backup interrupted by $signal during $CURRENT_PHASE" >&2
  if [[ $signal == INT ]]; then
    exit 130
  fi
  exit 143
}

read_error_log() {
  [[ -s $ERROR_LOG ]] || return 0
  LC_ALL=C tail -c 8192 "$ERROR_LOG" |
    LC_ALL=C tr -d '\000-\011\013-\037\177' |
    LC_ALL=C sed 's/[^[:print:]]/?/g' |
    tail -n 20
}

fail_with_error_log() {
  local message=$1
  local detail

  detail=$(read_error_log)
  if [[ -n $detail ]]; then
    die "$message: $detail"
  fi
  die "$message"
}

acquire_lock() {
  local managed_path

  CURRENT_PHASE=locking
  for managed_path in "$TARGET_DIR/.staging" "$TARGET_DIR/runs"; do
    [[ ! -L $managed_path ]] || die "Managed path must not be a symbolic link: $managed_path"
  done
  mkdir -p -- "$TARGET_DIR/.staging" "$TARGET_DIR/runs" || die "Cannot prepare backup directories"
  for managed_path in "$TARGET_DIR/.staging" "$TARGET_DIR/runs"; do
    validate_managed_directory "$managed_path"
  done
  exec 9<"$TARGET_DIR" || die "Cannot open target directory for locking"
  flock -n 9 || die "Another backup task is already running for $TARGET_DIR"
}

validate_managed_directory() {
  local path=$1
  local mode
  local owner
  local current_uid

  [[ -d $path && ! -L $path ]] || die "Managed path must be a real directory: $path"
  mode=$(file_mode "$path") || die "Cannot read managed directory permissions: $path"
  [[ $mode =~ ^[0-7]+$ ]] || die "Invalid managed directory permissions: $path"
  (( (8#$mode & 022) == 0 )) || die "Managed directory must not be writable by group or others: $path"
  owner=$(file_owner "$path") || die "Cannot read managed directory owner: $path"
  current_uid=$(id -u) || die "Cannot determine the current user ID"
  [[ $owner == "$current_uid" ]] || die "Managed directory must be owned by the current user: $path"
}

create_stage() {
  CURRENT_PHASE=staging
  RUN_ID="pgback_$(date -u '+%Y%m%dT%H%M%SZ')_$$"
  STAGE_DIR=$(mktemp -d "$TARGET_DIR/.staging/${RUN_ID}.XXXXXX") || die "Cannot create staging directory"
  chmod 700 "$STAGE_DIR" || die "Cannot protect staging directory"
  ERROR_LOG="$TARGET_DIR/.staging/${RUN_ID}.error.log"
  (set -o noclobber; : > "$ERROR_LOG") 2>/dev/null || die "Cannot create runtime error log"
  chmod 600 "$ERROR_LOG" || die "Cannot protect runtime error log"
}

run_pg_command() {
  local database=$1 database_option=$2 client=$3
  shift 3
  local -a command=("$client" "$database_option" "$(database_conninfo "$database")")
  if [[ -z $SERVICE || $DB_HOST_EXPLICIT == true ]]; then command+=(--host "$DB_HOST"); fi
  if [[ -z $SERVICE || $DB_PORT_EXPLICIT == true ]]; then command+=(--port "$DB_PORT"); fi
  if [[ -z $SERVICE || $DB_USER_EXPLICIT == true ]]; then command+=(--username "$DB_USER"); fi
  command+=("$@")
  (
    unset PGPASSWORD PGPASSFILE PGSERVICE PGSERVICEFILE
    export PGDATABASE="$database"
    if [[ $PASSWORD_CONFIGURED == true ]]; then export PGPASSWORD="$DB_PASSWORD"; fi
    if [[ -n $PASSFILE ]]; then export PGPASSFILE="$PASSFILE"; fi
    if [[ -n $SERVICE ]]; then export PGSERVICE="$SERVICE"; fi
    if [[ -n $SERVICE_FILE ]]; then export PGSERVICEFILE="$SERVICE_FILE"; fi
    exec "${command[@]}"
  )
}

database_conninfo() {
  local value=$1

  # A dbname option may otherwise reinterpret values containing '=' or a URI
  # prefix as a full conninfo string. Quote it as a conninfo literal instead.
  value=${value//\\/\\\\}
  value=${value//\'/\\\'}
  printf "dbname='%s'" "$value"
}

clear_secrets() { DB_PASSWORD=; unset PGPASSWORD PGPASSFILE PGSERVICEFILE 2>/dev/null || true; }

run_hook() {
  local label=$1 hook=$2
  [[ -n $hook ]] || return 0
  log INFO "Running $label hook: $hook"
  if ! env -i PATH="$PATH" HOME="${HOME:-}" BACKUP_RUN_ID="$RUN_ID" BACKUP_TARGET_DIR="$TARGET_DIR" BACKUP_STAGE_DIR="$STAGE_DIR" BACKUP_PUBLISHED_DIR="$PUBLISHED_DIR" "$hook" 2>"$ERROR_LOG"; then fail_with_error_log "$label hook failed"; fi
}

verify_database_artifact() {
  local file=$1 path="$STAGE_DIR/$1"
  [[ -f $path && ! -L $path && -s $path ]] || die "Database artifact is missing, linked, or empty: $file"
  if [[ $FORMAT == custom ]]; then
    pg_restore --list "$path" >/dev/null 2>"$ERROR_LOG" || fail_with_error_log "Custom archive verification failed: $file"
  else
    LC_ALL=C head -20 "$path" | grep -q 'PostgreSQL database dump' || die "Plain SQL header verification failed: $file"
  fi
}

verify_all_database_artifacts() {
  local file
  for file in "${DATABASE_FILES[@]}"; do verify_database_artifact "$file"; done
}

run_after_database_hook() {
  local database=$1 file=$2 index=$3
  [[ -n $AFTER_DATABASE_HOOK ]] || return 0
  CURRENT_PHASE=after-database-hook
  if ! env -i PATH="$PATH" HOME="${HOME:-}" BACKUP_RUN_ID="$RUN_ID" BACKUP_TARGET_DIR="$TARGET_DIR" BACKUP_STAGE_DIR="$STAGE_DIR" BACKUP_PUBLISHED_DIR="$PUBLISHED_DIR" BACKUP_DATABASE_NAME="$database" BACKUP_DATABASE_FILE="$STAGE_DIR/$file" BACKUP_DATABASE_INDEX="$index" BACKUP_DATABASE_TOTAL="$DATABASE_COUNT" BACKUP_DUMP_FORMAT="$FORMAT" BACKUP_OUTPUT_MODE="$OUTPUT_MODE" "$AFTER_DATABASE_HOOK" 2>"$ERROR_LOG"; then fail_with_error_log "After database hook failed"; fi
  verify_all_database_artifacts
}

query_scalar() {
  local sql=$1
  run_pg_command "$MAINTENANCE_DATABASE" --dbname psql -X -A -t -v ON_ERROR_STOP=1 --no-password --command "$sql"
}

check_connection_and_versions() {
  local server_num client_major server_major tool tool_version tool_major
  CURRENT_PHASE=connection-check
  server_num=$(query_scalar 'SHOW server_version_num' 2>"$ERROR_LOG") || fail_with_error_log "PostgreSQL connection check failed"
  [[ $server_num =~ ^[0-9]+$ ]] || die "Server returned an invalid version"
  SERVER_VERSION=$server_num
  CLIENT_VERSION=$(LC_ALL=C pg_dump --version 2>"$ERROR_LOG") || fail_with_error_log "Cannot read pg_dump version"
  client_major=$(printf '%s' "$CLIENT_VERSION" | sed -E 's/.* ([0-9]+)(\.[0-9]+)?.*/\1/')
  [[ $client_major =~ ^[0-9]+$ ]] || die "Cannot parse pg_dump version"
  for tool in pg_dumpall pg_restore psql; do
    tool_version=$(LC_ALL=C "$tool" --version 2>"$ERROR_LOG") || fail_with_error_log "Cannot read $tool version"
    tool_major=$(printf '%s' "$tool_version" | sed -E 's/.* ([0-9]+)(\.[0-9]+)?.*/\1/')
    [[ $tool_major =~ ^[0-9]+$ ]] || die "Cannot parse $tool version"
    [[ $tool_major == "$client_major" ]] || die "PostgreSQL client major versions do not match: pg_dump=$client_major, $tool=$tool_major"
  done
  server_major=$((server_num / 10000))
  (( client_major >= server_major )) || die "pg_dump client major version $client_major is older than server major version $server_major"
}

discover_databases() {
  local database discovery_file seen=$'\n'
  CURRENT_PHASE="database-discovery"
  if [[ ${#DATABASES[@]} -eq 0 ]]; then
    discovery_file=$STAGE_DIR/.databases.nul
    if ! run_pg_command "$MAINTENANCE_DATABASE" --dbname psql -X -A -t -0 -v ON_ERROR_STOP=1 --no-password --command "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname" >"$discovery_file" 2>"$ERROR_LOG"; then
      rm -f -- "$discovery_file"
      fail_with_error_log "Database discovery failed"
    fi
    while IFS= read -r -d '' database; do DATABASES+=("$database"); done <"$discovery_file"
    rm -f -- "$discovery_file"
    [[ ${#DATABASES[@]} -gt 0 ]] || fail_with_error_log "No connectable non-template databases were discovered"
  fi
  for database in "${DATABASES[@]}"; do
    [[ -n $database ]] || die "Database names cannot be empty"
    ! value_has_control_characters "$database" || die "Database names cannot contain control characters"
    [[ $seen != *$'\n'"$database"$'\n'* ]] || die "Duplicate database: $database"
    seen+="$database"$'\n'
  done
  DATABASE_COUNT=${#DATABASES[@]}
  if [[ $GLOBALS_MODE == include || ( $GLOBALS_MODE == auto && $ALL_DATABASES_REQUESTED == true ) ]]; then ACTUAL_GLOBALS_MODE=include; else ACTUAL_GLOBALS_MODE=skip; fi
}

safe_database_slug() {
  local slug
  slug=$(LC_ALL=C printf '%s' "$1" | sed 's/[^A-Za-z0-9_.-]/_/g'); [[ -n $slug ]] || slug=database
  printf '%s\n' "${slug:0:MAX_DATABASE_SLUG_BYTES}"
}

dump_databases() {
  local index database filename extension started ended
  CURRENT_PHASE=dumping-database
  [[ $FORMAT == custom ]] && extension=dump || extension=sql
  for ((index=0; index<DATABASE_COUNT; index++)); do
    database=${DATABASES[$index]}; printf -v filename '%03d_%s.%s' "$((index+1))" "$(safe_database_slug "$database")" "$extension"
    DATABASE_FILES+=("$filename"); started=$(date +%s); DATABASE_STARTS+=("$started")
    log INFO "Dumping database $((index+1))/$DATABASE_COUNT"
    if [[ $FORMAT == custom ]]; then
      run_pg_command "$database" --dbname pg_dump --create --no-password --file "$STAGE_DIR/$filename" --format custom --compress "$COMPRESSION" "${DUMP_OPTIONS[@]}" 2>"$ERROR_LOG" || fail_with_error_log "pg_dump failed"
    else
      run_pg_command "$database" --dbname pg_dump --create --no-password --file "$STAGE_DIR/$filename" --format plain "${DUMP_OPTIONS[@]}" 2>"$ERROR_LOG" || fail_with_error_log "pg_dump failed"
    fi
    verify_database_artifact "$filename"
    run_after_database_hook "$database" "$filename" "$((index+1))"
    ended=$(date +%s); DATABASE_ENDS+=("$ended"); DATABASE_SIZES+=("$(file_size "$STAGE_DIR/$filename")")
  done
}

dump_globals() {
  [[ $ACTUAL_GLOBALS_MODE == include ]] || return 0
  CURRENT_PHASE=dumping-globals
  [[ $INCLUDE_ROLE_PASSWORDS == true ]] && log WARN "Role password hashes will be included in globals.sql"
  if [[ $INCLUDE_ROLE_PASSWORDS == true ]]; then
    run_pg_command "$MAINTENANCE_DATABASE" --database pg_dumpall --globals-only --no-password --file "$STAGE_DIR/globals.sql" 2>"$ERROR_LOG" || fail_with_error_log "pg_dumpall failed"
  else
    run_pg_command "$MAINTENANCE_DATABASE" --database pg_dumpall --globals-only --no-password --file "$STAGE_DIR/globals.sql" --no-role-passwords 2>"$ERROR_LOG" || fail_with_error_log "pg_dumpall failed"
  fi
  [[ -f $STAGE_DIR/globals.sql && ! -L $STAGE_DIR/globals.sql && -s $STAGE_DIR/globals.sql ]] || die "pg_dumpall created an invalid globals.sql"
}

create_archive() {
  local -a names=("${DATABASE_FILES[@]}")
  if [[ $ACTUAL_GLOBALS_MODE == include ]]; then names+=(globals.sql); fi
  [[ $OUTPUT_MODE == zip || $OUTPUT_MODE == both ]] || return 0
  CURRENT_PHASE=compressing
  if [[ $FORMAT == custom ]]; then (cd "$STAGE_DIR" && zip -q -0 backup.zip.tmp "${names[@]}") || die "zip failed"; else (cd "$STAGE_DIR" && zip -q backup.zip.tmp "${names[@]}") || die "zip failed"; fi
  zip -T "$STAGE_DIR/backup.zip.tmp" >/dev/null || die "backup.zip failed its integrity check"
  mv -- "$STAGE_DIR/backup.zip.tmp" "$STAGE_DIR/backup.zip" || die "Cannot finalize backup.zip"
  if [[ $OUTPUT_MODE == zip ]]; then
    rm -- "${DATABASE_FILES[@]/#/$STAGE_DIR/}"
    if [[ $ACTUAL_GLOBALS_MODE == include ]]; then rm -- "$STAGE_DIR/globals.sql"; fi
  fi
}

write_manifest() {
  local end_epoch index artifact
  CURRENT_PHASE=manifest; end_epoch=$(date +%s)
  { printf 'format_version=1\nrun_id=%s\n' "$RUN_ID"; } > "$STAGE_DIR/.pgsql-onekey-backup-run" || die "Cannot write ownership marker"
  {
    printf 'format_version=1\nscript_version=%s\nrun_id=%s\nserver_name=%s\n' "$SCRIPT_VERSION" "$RUN_ID" "$SERVER_NAME"
    printf 'connection_source=%s\ncredential_source=%s\n' "$CONNECTION_SOURCE" "$CREDENTIAL_SOURCE"
    printf 'db_host=%s\ndb_port=%s\ndb_user=%s\nmaintenance_database=%s\n' "$(connection_value "$DB_HOST_EXPLICIT" "$DB_HOST")" "$(connection_value "$DB_PORT_EXPLICIT" "$DB_PORT")" "$(connection_value "$DB_USER_EXPLICIT" "$DB_USER")" "$MAINTENANCE_DATABASE"
    printf 'client_version=%s\nserver_version=%s\ndump_format=%s\noutput_mode=%s\nglobals_mode=%s\nrole_passwords_included=%s\n' "$CLIENT_VERSION" "$SERVER_VERSION" "$FORMAT" "$OUTPUT_MODE" "$ACTUAL_GLOBALS_MODE" "$INCLUDE_ROLE_PASSWORDS"
    printf 'started_epoch=%s\nfinished_epoch=%s\nelapsed_seconds=%s\n' "$START_EPOCH" "$end_epoch" "$((end_epoch-START_EPOCH))"
    for ((index=0; index<DATABASE_COUNT; index++)); do printf 'database=%s\ndatabase_file=%s\ndatabase_started_epoch=%s\ndatabase_finished_epoch=%s\ndatabase_size_bytes=%s\n' "${DATABASES[$index]}" "${DATABASE_FILES[$index]}" "${DATABASE_STARTS[$index]}" "${DATABASE_ENDS[$index]}" "${DATABASE_SIZES[$index]}"; done
    while IFS= read -r -d '' artifact; do printf 'artifact=%s\n' "$(basename "$artifact")"; done < <(find "$STAGE_DIR" -maxdepth 1 -type f \( -name '*.sql' -o -name '*.dump' -o -name '*.zip' \) -print0)
  } > "$STAGE_DIR/manifest.txt" || die "Cannot write manifest"
}

write_and_verify_checksums() {
  local -a artifacts=(); local artifact
  CURRENT_PHASE=checksums
  while IFS= read -r -d '' artifact; do artifacts+=("$(basename "$artifact")"); done < <(find "$STAGE_DIR" -maxdepth 1 -type f ! -name 'SHA256SUMS' -print0)
  [[ ${#artifacts[@]} -gt 0 ]] || die "No backup artifacts were created"
  if command_exists sha256sum; then (cd "$STAGE_DIR" && sha256sum -- "${artifacts[@]}" > SHA256SUMS && sha256sum -c SHA256SUMS >/dev/null) || die "SHA256 verification failed"; else (cd "$STAGE_DIR" && shasum -a 256 "${artifacts[@]}" > SHA256SUMS && shasum -a 256 -c SHA256SUMS >/dev/null) || die "SHA256 verification failed"; fi
}

publish_run() { local target="$TARGET_DIR/runs/$RUN_ID"; CURRENT_PHASE=publishing; [[ ! -e $target ]] || die "Backup run already exists"; mv -- "$STAGE_DIR" "$target" || die "Cannot publish backup run"; PUBLISHED_DIR=$target; STAGE_DIR=; }

identity_file_matches() {
  local path=$1 expected=$2
  [[ -f $path && ! -L $path ]] || return 1
  [[ $(grep -c '^format_version=1$' "$path") -eq 1 && $(grep -c "^run_id=$expected$" "$path") -eq 1 ]]
}
managed_run_is_valid() { local id; [[ -d $1 && ! -L $1 ]] || return 1; id=$(basename "$1"); identity_file_matches "$1/.pgsql-onekey-backup-run" "$id" && identity_file_matches "$1/manifest.txt" "$id"; }

prune_expired_runs() {
  local minutes expired
  (( EXPIRE_HOURS > 0 )) || return 0; CURRENT_PHASE=retention; minutes=$((EXPIRE_HOURS*60))
  while IFS= read -r -d '' expired; do case "$expired" in "$TARGET_DIR"/runs/pgback_*) if managed_run_is_valid "$expired"; then log INFO "Removing expired managed run: $expired"; rm -rf -- "$expired" || die "Cannot remove expired run"; else log WARN "Skipping unrecognized backup directory: $expired"; fi ;; *) die "Refusing unexpected retention path" ;; esac; done < <(find "$TARGET_DIR/runs" -mindepth 1 -maxdepth 1 -type d -name 'pgback_*' -mmin "+$minutes" -print0)
}

run_backup() {
  local end_epoch
  trap on_exit EXIT; trap 'on_signal INT' INT; trap 'on_signal TERM' TERM; NOTIFY_ON_EXIT=true
  START_EPOCH=$(date +%s); acquire_lock; create_stage; check_connection_and_versions; discover_databases
  [[ $NOTIFY_START == false ]] || notify_event start "Backup started" || true
  CURRENT_PHASE=before-hook; run_hook Before "$BEFORE_HOOK"
  dump_databases; dump_globals; verify_all_database_artifacts; create_archive; write_manifest; write_and_verify_checksums; publish_run; prune_expired_runs
  clear_secrets; CURRENT_PHASE=after-hook; run_hook After "$AFTER_HOOK"
  CURRENT_PHASE=complete; end_epoch=$(date +%s); FINAL_MESSAGE="Backup completed: $PUBLISHED_DIR ($((end_epoch-START_EPOCH))s)"; log INFO "$FINAL_MESSAGE"
}

main() {
  if (( BASH_VERSINFO[0] < 4 || ( BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2 ) )); then
    printf 'Bash 4.2 or newer is required.\n' >&2
    return 2
  fi
  load_environment_lists; parse_args "$@"
  if [[ $DEPENDENCY_REPORT == true ]]; then case "$OUTPUT_MODE" in zip|files|both) ;; *) die "--output-mode must be zip, files, or both" ;; esac; show_dependency_report; return $?; fi
  validate_config
  if [[ $CHECK_ONLY == true ]]; then log INFO "Configuration check passed."; return 0; fi
  run_backup
}

main "$@"
