#!/usr/bin/env bash

# ============================================================================
# MySQL OneKey Backup
#
# Version: v1.2.0
# Author:  Leon (silenceace@gmail.com)
# Repo:    https://github.com/funnyzak/mysql-onekey-backup
# License: MIT
#
# Requirements: Bash 4.2+, mysqldump, flock, sha256sum/shasum, and optionally
# mysql and zip. mysql is required for separate all-database dumps; 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 mysql_backup.sh \
#     --target-dir /var/backups/mysql \
#     --defaults-extra-file /etc/mysql-backup/client.cnf \
#     --database app --database analytics
#
# Password environment-variable example (preferred over --password for Cron):
#   export MYSQL_BACKUP_PASSWORD='replace-with-your-password'
#   bash mysql_backup.sh \
#     --target-dir /var/backups/mysql \
#     --host 127.0.0.1 --port 3306 --user backup \
#     --database app
#
# Password command-line example (may be visible in ps and shell history):
#   bash mysql_backup.sh \
#     --target-dir /var/backups/mysql \
#     --host 127.0.0.1 --port 3306 --user backup \
#     --password 'replace-with-your-password' \
#     --database app
#
# Check configuration without connecting, backing up, or sending notifications:
#   bash mysql_backup.sh --target-dir /var/backups/mysql --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 MYSQL_BACKUP_BARK_SERVER=https://api.day.app
#   export MYSQL_BACKUP_BARK_DEVICE_KEY=replace-with-device-key
#   export MYSQL_BACKUP_BARK_SOUND=alarm                 # optional
#   export MYSQL_BACKUP_BARK_GROUP=mysql-backup          # optional
#   export MYSQL_BACKUP_NTFY_SERVER=https://ntfy.sh
#   export MYSQL_BACKUP_NTFY_TOPIC=mysql-backup
#   export MYSQL_BACKUP_NTFY_TOKEN=replace-with-token    # optional
#   export MYSQL_BACKUP_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/replace-me
#   export MYSQL_BACKUP_WECOM_WEBHOOK_URL='https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me'
#   export MYSQL_BACKUP_DINGTALK_WEBHOOK_URL='https://oapi.dingtalk.com/robot/send?access_token=replace-me'
#   export MYSQL_BACKUP_GOTIFY_SERVER=https://gotify.example.com
#   export MYSQL_BACKUP_GOTIFY_TOKEN=replace-with-app-token
#   export MYSQL_BACKUP_GOTIFY_PRIORITY=5                # optional
#   export MYSQL_BACKUP_NOTIFY_REDACT=true               # optional, default
#   export MYSQL_BACKUP_NOTIFY_TIMEOUT=10                # optional, 1-300
#   bash mysql_backup.sh --target-dir /var/backups/mysql --database app
#
# The same Bark settings can be written as command-line options:
#   bash mysql_backup.sh \
#     --target-dir /var/backups/mysql --database app \
#     --bark-server https://api.day.app \
#     --bark-device-key replace-with-device-key \
#     --bark-sound alarm --bark-group mysql-backup --notify-timeout 10
#
# Apprise API supports both stateful and stateless curl calls:
#   export MYSQL_BACKUP_APPRISE_API_URL=http://127.0.0.1:8000/notify/mysql-backup
#   # For stateless /notify, also set MYSQL_BACKUP_APPRISE_URLS.
# Apprise CLI remains available when MYSQL_BACKUP_APPRISE_CONFIG points to a
# protected Apprise configuration file. MYSQL_BACKUP_APPRISE_TAGS accepts a
# comma-separated tag expression.
#   export MYSQL_BACKUP_APPRISE_CONFIG=/etc/mysql-backup/apprise.conf
#   export MYSQL_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/mysql-backup/mysql_backup.sh --target-dir /var/backups/mysql --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.2.0"
readonly PROGRAM_NAME="mysql-onekey-backup"
readonly MAX_DATABASE_SLUG_BYTES=160

TARGET_DIR=${MYSQL_BACKUP_TARGET_DIR:-}
DB_HOST=${MYSQL_BACKUP_HOST:-127.0.0.1}
DB_PORT=${MYSQL_BACKUP_PORT:-3306}
DB_USER=${MYSQL_BACKUP_USER:-root}
DB_PASSWORD=${MYSQL_BACKUP_PASSWORD:-}
DEFAULTS_EXTRA_FILE=${MYSQL_BACKUP_DEFAULTS_FILE:-}
LOGIN_PATH=${MYSQL_BACKUP_LOGIN_PATH:-}
OUTPUT_MODE=${MYSQL_BACKUP_OUTPUT_MODE:-zip}
EXPIRE_HOURS=${MYSQL_BACKUP_EXPIRE_HOURS:-4320}
SERVER_NAME=${MYSQL_BACKUP_SERVER_NAME:-$(hostname 2>/dev/null || printf 'unknown')}
NOTIFY_START=${MYSQL_BACKUP_NOTIFY_START:-false}
NOTIFY_REDACT=${MYSQL_BACKUP_NOTIFY_REDACT:-true}
NOTIFY_TIMEOUT=${MYSQL_BACKUP_NOTIFY_TIMEOUT:-10}
BEFORE_HOOK=${MYSQL_BACKUP_BEFORE_HOOK:-}
AFTER_DATABASE_HOOK=${MYSQL_BACKUP_AFTER_DATABASE_HOOK:-}
AFTER_HOOK=${MYSQL_BACKUP_AFTER_HOOK:-}
BEFORE_HOOK_COMMAND=${MYSQL_BACKUP_BEFORE_HOOK_COMMAND:-}
AFTER_DATABASE_HOOK_COMMAND=${MYSQL_BACKUP_AFTER_DATABASE_HOOK_COMMAND:-}
AFTER_HOOK_COMMAND=${MYSQL_BACKUP_AFTER_HOOK_COMMAND:-}
DATABASE_VALUES=${MYSQL_BACKUP_DATABASES:-}
DUMP_OPTION_VALUES=${MYSQL_BACKUP_DUMP_OPTIONS:-}
SEPARATE_FILES=${MYSQL_BACKUP_SEPARATE_FILES:-true}
CHECK_ONLY=${MYSQL_BACKUP_CHECK:-false}
DEPENDENCY_REPORT=false

APPRISE_CLI_CONFIG=${MYSQL_BACKUP_APPRISE_CONFIG:-}
APPRISE_API_URL=${MYSQL_BACKUP_APPRISE_API_URL:-}
APPRISE_API_URLS=${MYSQL_BACKUP_APPRISE_URLS:-}
APPRISE_TAGS=${MYSQL_BACKUP_APPRISE_TAGS:-}
BARK_SERVER=${MYSQL_BACKUP_BARK_SERVER:-}
BARK_DEVICE_KEY=${MYSQL_BACKUP_BARK_DEVICE_KEY:-}
BARK_SOUND=${MYSQL_BACKUP_BARK_SOUND:-}
BARK_GROUP=${MYSQL_BACKUP_BARK_GROUP:-}
NTFY_SERVER=${MYSQL_BACKUP_NTFY_SERVER:-}
NTFY_TOPIC=${MYSQL_BACKUP_NTFY_TOPIC:-}
NTFY_TOKEN=${MYSQL_BACKUP_NTFY_TOKEN:-}
FEISHU_WEBHOOK_URL=${MYSQL_BACKUP_FEISHU_WEBHOOK_URL:-}
WECOM_WEBHOOK_URL=${MYSQL_BACKUP_WECOM_WEBHOOK_URL:-}
DINGTALK_WEBHOOK_URL=${MYSQL_BACKUP_DINGTALK_WEBHOOK_URL:-}
GOTIFY_SERVER=${MYSQL_BACKUP_GOTIFY_SERVER:-}
GOTIFY_TOKEN=${MYSQL_BACKUP_GOTIFY_TOKEN:-}
GOTIFY_PRIORITY=${MYSQL_BACKUP_GOTIFY_PRIORITY:-5}

DB_HOST_EXPLICIT=false
DB_PORT_EXPLICIT=false
DB_USER_EXPLICIT=false
PASSWORD_CONFIGURED=false
if [[ -n ${MYSQL_BACKUP_HOST+x} ]]; then
  DB_HOST_EXPLICIT=true
fi
if [[ -n ${MYSQL_BACKUP_PORT+x} ]]; then
  DB_PORT_EXPLICIT=true
fi
if [[ -n ${MYSQL_BACKUP_USER+x} ]]; then
  DB_USER_EXPLICIT=true
fi
if [[ -n $DB_PASSWORD ]]; then
  PASSWORD_CONFIGURED=true
fi

export -n \
  TARGET_DIR \
  DB_HOST \
  DB_PORT \
  DB_USER \
  DB_PASSWORD \
  DB_HOST_EXPLICIT \
  DB_PORT_EXPLICIT \
  DB_USER_EXPLICIT \
  PASSWORD_CONFIGURED \
  DEFAULTS_EXTRA_FILE \
  LOGIN_PATH \
  OUTPUT_MODE \
  EXPIRE_HOURS \
  SERVER_NAME \
  NOTIFY_START \
  NOTIFY_REDACT \
  NOTIFY_TIMEOUT \
  BEFORE_HOOK \
  AFTER_DATABASE_HOOK \
  AFTER_HOOK \
  BEFORE_HOOK_COMMAND \
  AFTER_DATABASE_HOOK_COMMAND \
  AFTER_HOOK_COMMAND \
  DATABASE_VALUES \
  DUMP_OPTION_VALUES \
  SEPARATE_FILES \
  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

# Keep script configuration in this shell only. Child processes receive only
# the explicit BACKUP_* hook variables and notification request values.
unset \
  MYSQL_BACKUP_TARGET_DIR \
  MYSQL_BACKUP_HOST \
  MYSQL_BACKUP_PORT \
  MYSQL_BACKUP_USER \
  MYSQL_BACKUP_PASSWORD \
  MYSQL_BACKUP_DATABASES \
  MYSQL_BACKUP_DEFAULTS_FILE \
  MYSQL_BACKUP_LOGIN_PATH \
  MYSQL_BACKUP_DUMP_OPTIONS \
  MYSQL_BACKUP_SEPARATE_FILES \
  MYSQL_BACKUP_OUTPUT_MODE \
  MYSQL_BACKUP_EXPIRE_HOURS \
  MYSQL_BACKUP_SERVER_NAME \
  MYSQL_BACKUP_NOTIFY_START \
  MYSQL_BACKUP_NOTIFY_REDACT \
  MYSQL_BACKUP_NOTIFY_TIMEOUT \
  MYSQL_BACKUP_BEFORE_HOOK \
  MYSQL_BACKUP_AFTER_DATABASE_HOOK \
  MYSQL_BACKUP_AFTER_HOOK \
  MYSQL_BACKUP_BEFORE_HOOK_COMMAND \
  MYSQL_BACKUP_AFTER_DATABASE_HOOK_COMMAND \
  MYSQL_BACKUP_AFTER_HOOK_COMMAND \
  MYSQL_BACKUP_CHECK \
  MYSQL_BACKUP_APPRISE_CONFIG \
  MYSQL_BACKUP_APPRISE_API_URL \
  MYSQL_BACKUP_APPRISE_URLS \
  MYSQL_BACKUP_APPRISE_TAGS \
  MYSQL_BACKUP_BARK_SERVER \
  MYSQL_BACKUP_BARK_DEVICE_KEY \
  MYSQL_BACKUP_BARK_SOUND \
  MYSQL_BACKUP_BARK_GROUP \
  MYSQL_BACKUP_NTFY_SERVER \
  MYSQL_BACKUP_NTFY_TOPIC \
  MYSQL_BACKUP_NTFY_TOKEN \
  MYSQL_BACKUP_FEISHU_WEBHOOK_URL \
  MYSQL_BACKUP_WECOM_WEBHOOK_URL \
  MYSQL_BACKUP_DINGTALK_WEBHOOK_URL \
  MYSQL_BACKUP_GOTIFY_SERVER \
  MYSQL_BACKUP_GOTIFY_TOKEN \
  MYSQL_BACKUP_GOTIFY_PRIORITY

declare -a DATABASES=()
declare -a DUMP_OPTIONS=(--single-transaction --quick --skip-lock-tables)
declare -a MYSQLDUMP_COMMAND=()
declare -a MYSQL_COMMAND=()
declare -a SQL_FILES=()
declare -a NOTIFICATION_NAMES=()
declare -a NOTIFICATION_SENDERS=()
declare -a NOTIFICATION_VALIDATORS=()
declare -a MISSING_DEPENDENCIES=()
declare -a MISSING_DEPENDENCY_KINDS=()

DATABASE_COUNT=0
CLI_DATABASES_SET=false
CLI_DUMP_OPTIONS_SET=false
CLI_DEFAULTS_FILE_SET=false
CLI_LOGIN_PATH_SET=false
CLI_PASSWORD_SET=false
NOTIFICATION_CHANNEL_COUNT=0
TIMEOUT_COMMAND=
MYSQLDUMP_DISABLE_LOGIN_PATHS=false
ALL_DATABASES_REQUESTED=false
RUN_ID=
STAGE_DIR=
PUBLISHED_DIR=
ERROR_LOG=
CURRENT_PHASE=initialization
START_EPOCH=0
FINAL_MESSAGE=
NOTIFY_ON_EXIT=false

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

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

Database:
  --host HOST                  Database host (default: 127.0.0.1)
  --port PORT                  Database port (default: 3306)
  --user USER                  Database user (default: root)
  --password PASSWORD          Database password; see security warning below
  --database NAME              Database to back up; repeat as needed
  --all-databases              Override MYSQL_BACKUP_DATABASES and back up all
  --defaults-extra-file FILE   MySQL client option file
  --login-path NAME            MySQL login path
  --dump-option OPTION         Allowlisted mysqldump option; repeat as needed
  --default-dump-options       Ignore MYSQL_BACKUP_DUMP_OPTIONS
  --single-file                Put databases in one SQL file
  --separate-files             Put each database in its own SQL file (default)

Output:
  --output-mode MODE           zip, sql, or both (default: zip)
  --expire-hours HOURS         Delete managed runs older than HOURS; 0 disables
  --before-hook FILE           Executable run before mysqldump
  --before-hook-command CMD    Shell command run before mysqldump
  --after-database-hook FILE   Executable run after each separate database dump
  --after-database-hook-command CMD
                               Shell command run after each separate database dump
  --after-hook FILE            Executable run after publishing and pruning
  --after-hook-command CMD     Shell command run after publishing and pruning

Notifications:
  --notify-start               Also send a start notification
  --no-notify-start            Do not send a start notification
  --notify-redact              Hide sensitive notification details (default)
  --no-notify-redact           Include connection, database, path, errors
  --notify-timeout SECONDS     Notification timeout, 1-300 (default: 10)
  --apprise-config FILE        Protected Apprise CLI configuration file
  --apprise-api-url URL        Apprise API /notify or /notify/{key} URL
  --apprise-urls URLS          Stateless Apprise service URLs
  --apprise-tags TAGS          Apprise tag expression
  --bark-server URL            Bark server URL
  --bark-device-key KEY        Bark device key
  --bark-sound SOUND           Bark sound name
  --bark-group GROUP           Bark notification group
  --ntfy-server URL            ntfy server URL
  --ntfy-topic TOPIC           ntfy topic
  --ntfy-token TOKEN           ntfy access token
  --feishu-webhook-url URL     Feishu robot webhook
  --wecom-webhook-url URL      WeCom robot webhook
  --dingtalk-webhook-url URL   DingTalk robot webhook
  --gotify-server URL          Gotify server URL
  --gotify-token TOKEN         Gotify application token
  --gotify-priority NUMBER     Gotify priority, -9999 to 9999 (default: 5)

Other:
  --server-name NAME           Name shown in notifications
  --check                      Validate configuration without backing up
  --no-check                   Run the backup when MYSQL_BACKUP_CHECK=true
  --dependency-report          List required tools and installation guidance
  -h, --help                   Show this help
  -V, --version                Show version

Every configurable setting has an environment-variable and command-line form.
Command-line values override environment variables. Secret command-line values
may be visible to local process-list viewers or saved in shell history; see
README.md before using Cron.
MYSQL_BACKUP_PASSWORD and --password are supported. The script sends the
  password to MySQL client commands through an anonymous file descriptor; it is
  not written to disk. A protected option file or login path is safer for production.
Newline-separated environment values map to repeated --database and
--dump-option arguments. See README.md for the complete mapping.
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() {
  local option=$1
  local count=$2
  [[ $count -ge 2 ]] || die "$option requires a value"
}

validate_dump_option() {
  local option=$1

  ! value_has_control_characters "$option" || die "Dump options cannot contain control characters"
  case "$option" in
    --password|--password=*|-p|-p?*|--defaults-extra-file|--defaults-extra-file=*|--login-path|--login-path=*|--no-defaults)
      die "Credential options are not allowed through --dump-option"
      ;;
    --add-drop-database|--add-drop-table|--add-locks|--allow-keywords|--comments|--compact|--complete-insert|--create-options|--disable-keys|--dump-date|--events|--extended-insert|--force|--hex-blob|--insert-ignore|--lock-tables|--no-autocommit|--no-create-db|--no-create-info|--no-data|--no-set-names|--no-tablespaces|--opt|--order-by-primary|--quick|--quote-names|--replace|--routines|--single-transaction|--skip-add-drop-table|--skip-add-locks|--skip-comments|--skip-disable-keys|--skip-dump-date|--skip-extended-insert|--skip-lock-tables|--skip-opt|--skip-quick|--skip-quote-names|--skip-triggers|--skip-tz-utc|--triggers|--tz-utc)
      return 0
      ;;
    --column-statistics=0|--column-statistics=1|--set-gtid-purged=OFF|--set-gtid-purged=ON|--set-gtid-purged=AUTO|--source-data=1|--source-data=2|--master-data=1|--master-data=2)
      return 0
      ;;
    --compatible=*|--default-character-set=*|--ignore-table=*|--ignore-table-data=*|--max-allowed-packet=*|--net-buffer-length=*|--where=*)
      return 0
      ;;
    *)
      die "Unsupported --dump-option: $option"
      ;;
  esac
}

load_environment_lists() {
  local value

  if [[ -n $DATABASE_VALUES ]]; then
    while IFS= read -r value; do
      [[ -n $value ]] || continue
      DATABASES+=("$value")
    done <<< "$DATABASE_VALUES"
    DATABASE_COUNT=${#DATABASES[@]}
  fi

  if [[ -n $DUMP_OPTION_VALUES ]]; then
    while IFS= read -r value; do
      [[ -n $value ]] || continue
      DUMP_OPTIONS+=("$value")
    done <<< "$DUMP_OPTION_VALUES"
  fi
}

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" "$#"
        [[ $CLI_DEFAULTS_FILE_SET == false && $CLI_LOGIN_PATH_SET == false ]] || die "Use only one of --password, --defaults-extra-file, or --login-path"
        [[ -n $2 ]] || die "--password cannot be empty"
        DB_PASSWORD=$2
        PASSWORD_CONFIGURED=true
        DEFAULTS_EXTRA_FILE=
        LOGIN_PATH=
        CLI_PASSWORD_SET=true
        shift 2
        ;;
      --database)
        require_value "$1" "$#"
        if [[ $CLI_DATABASES_SET == false ]]; then
          DATABASES=()
          DATABASE_COUNT=0
          CLI_DATABASES_SET=true
        fi
        [[ -n $2 ]] || die "--database cannot be empty"
        [[ $2 != -* ]] || die "Database names cannot start with '-'"
        DATABASES+=("$2")
        DATABASE_COUNT=$((DATABASE_COUNT + 1))
        shift 2
        ;;
      --all-databases)
        DATABASES=()
        DATABASE_COUNT=0
        CLI_DATABASES_SET=true
        shift
        ;;
      --defaults-extra-file)
        require_value "$1" "$#"
        [[ $CLI_LOGIN_PATH_SET == false && $CLI_PASSWORD_SET == false ]] || die "Use only one of --password, --defaults-extra-file, or --login-path"
        [[ -n $2 ]] || die "--defaults-extra-file cannot be empty"
        DEFAULTS_EXTRA_FILE=$2
        LOGIN_PATH=
        DB_PASSWORD=
        PASSWORD_CONFIGURED=false
        CLI_DEFAULTS_FILE_SET=true
        shift 2
        ;;
      --login-path)
        require_value "$1" "$#"
        [[ $CLI_DEFAULTS_FILE_SET == false && $CLI_PASSWORD_SET == false ]] || die "Use only one of --password, --defaults-extra-file, or --login-path"
        [[ -n $2 ]] || die "--login-path cannot be empty"
        LOGIN_PATH=$2
        DEFAULTS_EXTRA_FILE=
        DB_PASSWORD=
        PASSWORD_CONFIGURED=false
        CLI_LOGIN_PATH_SET=true
        shift 2
        ;;
      --dump-option)
        require_value "$1" "$#"
        if [[ $CLI_DUMP_OPTIONS_SET == false ]]; then
          DUMP_OPTIONS=(--single-transaction --quick --skip-lock-tables)
          CLI_DUMP_OPTIONS_SET=true
        fi
        validate_dump_option "$2"
        DUMP_OPTIONS+=("$2")
        shift 2
        ;;
      --default-dump-options)
        DUMP_OPTIONS=(--single-transaction --quick --skip-lock-tables)
        CLI_DUMP_OPTIONS_SET=true
        shift
        ;;
      --single-file)
        SEPARATE_FILES=false
        shift
        ;;
      --separate-files)
        SEPARATE_FILES=true
        shift
        ;;
      --output-mode)
        require_value "$1" "$#"
        OUTPUT_MODE=$2
        shift 2
        ;;
      --expire-hours)
        require_value "$1" "$#"
        EXPIRE_HOURS=$2
        shift 2
        ;;
      --before-hook)
        require_value "$1" "$#"
        BEFORE_HOOK=$2
        BEFORE_HOOK_COMMAND=
        shift 2
        ;;
      --before-hook-command)
        require_value "$1" "$#"
        BEFORE_HOOK_COMMAND=$2
        BEFORE_HOOK=
        shift 2
        ;;
      --after-database-hook)
        require_value "$1" "$#"
        AFTER_DATABASE_HOOK=$2
        AFTER_DATABASE_HOOK_COMMAND=
        shift 2
        ;;
      --after-database-hook-command)
        require_value "$1" "$#"
        AFTER_DATABASE_HOOK_COMMAND=$2
        AFTER_DATABASE_HOOK=
        shift 2
        ;;
      --after-hook)
        require_value "$1" "$#"
        AFTER_HOOK=$2
        AFTER_HOOK_COMMAND=
        shift 2
        ;;
      --after-hook-command)
        require_value "$1" "$#"
        AFTER_HOOK_COMMAND=$2
        AFTER_HOOK=
        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=*|-p|-p?*)
        die "Inline password forms are not supported; use --password PASSWORD"
        ;;
      --apprise-api-url=*|--apprise-urls=*|--bark-server=*|--bark-device-key=*|--ntfy-server=*|--ntfy-token=*|--feishu-webhook-url=*|--wecom-webhook-url=*|--dingtalk-webhook-url=*|--gotify-server=*|--gotify-token=*)
        die "Inline secret forms are not supported; pass the option and value as separate arguments"
        ;;
      *)
        die "Unknown option: $1"
        ;;
    esac
  done
}

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
  local mysql_needed=false

  MISSING_DEPENDENCIES=()
  MISSING_DEPENDENCY_KINDS=()

  [[ $OUTPUT_MODE == zip || $OUTPUT_MODE == both ]] && zip_needed=true
  [[ $DATABASE_COUNT -eq 0 && $SEPARATE_FILES == true ]] && mysql_needed=true
  http_notifications_requested && curl_needed=true
  [[ -n $APPRISE_CLI_CONFIG ]] && apprise_needed=true

  inspect_dependency "mysqldump" mysql true "" "$verbose" mysqldump
  inspect_dependency "mysql" mysql "$mysql_needed" "single-file or explicit database mode" "$verbose" mysql
  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:mysql) printf 'default-mysql-client\n' ;;
    dnf:mysql|yum:mysql)
      if [[ $SYSTEM_ID == fedora ]]; then printf 'community-mysql\n'; else printf 'mysql\n'; fi
      ;;
    apk:mysql) printf 'mariadb-client\n' ;;
    zypper:mysql) printf 'mariadb-client\n' ;;
    pacman:mysql) printf 'mariadb-clients\n' ;;
    brew:mysql) printf 'mysql-client\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 '*.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"
}

validate_hook_command() {
  local label=$1
  local variable_name=$2
  local command=${!variable_name}

  [[ -z $command ]] && return 0
  ! value_has_control_characters "$command" || die "$label command cannot contain control characters"
}

validate_hook_configuration() {
  local label=$1
  local hook_variable=$2
  local command_variable=$3
  local hook=${!hook_variable}
  local command=${!command_variable}

  [[ -n $hook && -n $command ]] && die "$label must configure either a script or a command, not both"
  validate_hook "$label" "$hook_variable"
  validate_hook_command "$label" "$command_variable"
}

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 / MYSQL_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 / MYSQL_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() {
  [[ $NOTIFY_REDACT == true || $NOTIFY_REDACT == false ]] || die "Notification redaction setting must be true or false"
  ! value_has_control_characters "$SERVER_NAME" || die "Server name cannot contain control characters"
  [[ $CHECK_ONLY == true || $CHECK_ONLY == false ]] || die "Check setting must be true or false"
  case "$OUTPUT_MODE" in
    zip|sql|both) ;;
    *) die "--output-mode must be zip, sql, or both" ;;
  esac
  configure_notifications

  CURRENT_PHASE=validation
  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

  [[ -n $DB_HOST ]] || die "--host cannot be empty"
  [[ -n $DB_USER ]] || die "--user cannot be empty"
  ! value_has_control_characters "$DB_HOST" || die "Database host cannot contain control characters"
  ! value_has_control_characters "$DB_USER" || die "Database user cannot contain control characters"
  [[ $DB_PORT =~ ^[0-9]+$ ]] || die "--port must be between 1 and 65535"
  DB_PORT=$(normalize_decimal "$DB_PORT")
  if [[ ${#DB_PORT} -gt 5 ]] || (( DB_PORT < 1 || DB_PORT > 65535 )); then
    die "--port must be between 1 and 65535"
  fi
  [[ $EXPIRE_HOURS =~ ^[0-9]+$ ]] || die "--expire-hours must be a non-negative integer"
  EXPIRE_HOURS=$(normalize_decimal "$EXPIRE_HOURS")
  if [[ ${#EXPIRE_HOURS} -gt 7 ]] || (( EXPIRE_HOURS > 8760000 )); then
    die "--expire-hours must not exceed 8760000"
  fi
  [[ $NOTIFY_START == true || $NOTIFY_START == false ]] || die "Notify-start setting must be true or false"
  [[ $SEPARATE_FILES == true || $SEPARATE_FILES == false ]] || die "Separate-files setting must be true or false"

  local database
  local dump_option
  if [[ $DATABASE_COUNT -gt 0 ]]; then
    for database in "${DATABASES[@]}"; do
      [[ -n $database ]] || die "Database names cannot be empty"
      [[ $database != -* ]] || die "Database names cannot start with '-'"
      ! value_has_control_characters "$database" || die "Database names cannot contain control characters"
    done
  fi
  for dump_option in "${DUMP_OPTIONS[@]}"; do
    validate_dump_option "$dump_option"
  done

  if [[ $PASSWORD_CONFIGURED == true && ( -n $DEFAULTS_EXTRA_FILE || -n $LOGIN_PATH ) ]] || [[ -n $DEFAULTS_EXTRA_FILE && -n $LOGIN_PATH ]]; then
    die "Use only one of --password / MYSQL_BACKUP_PASSWORD, --defaults-extra-file, or --login-path"
  fi
  if [[ $PASSWORD_CONFIGURED == true ]] && value_has_control_characters "$DB_PASSWORD"; then
    die "Database password cannot contain control characters"
  fi
  if [[ -n $LOGIN_PATH ]] && value_has_control_characters "$LOGIN_PATH"; then
    die "Login Path cannot contain control characters"
  fi
  if [[ -n $DEFAULTS_EXTRA_FILE ]]; then
    validate_secret_file "MySQL option file" DEFAULTS_EXTRA_FILE
  fi

  validate_hook_configuration "Before hook" BEFORE_HOOK BEFORE_HOOK_COMMAND
  if [[ -n $AFTER_DATABASE_HOOK || -n $AFTER_DATABASE_HOOK_COMMAND ]]; then
    [[ $SEPARATE_FILES == true ]] || die "--after-database-hook requires --separate-files"
  fi
  validate_hook_configuration "After database hook" AFTER_DATABASE_HOOK AFTER_DATABASE_HOOK_COMMAND
  validate_hook_configuration "After hook" AFTER_HOOK AFTER_HOOK_COMMAND

  if ! validate_dependencies; then
    exit 2
  fi
  if [[ $PASSWORD_CONFIGURED == true ]]; then
    configure_password_authentication
  fi
}

configure_password_authentication() {
  local help_output

  help_output=$(LC_ALL=C mysqldump --no-defaults --verbose --help 2>/dev/null) || die "Cannot inspect mysqldump password-option support"
  [[ $help_output == *--defaults-file* ]] || die "mysqldump does not support the isolated --defaults-file password mode"
  if [[ $help_output == *--login-path* ]]; then
    [[ $help_output == *--no-login-paths* ]] || die "mysqldump can read Login Path but cannot disable it; direct password mode is unsafe"
    MYSQLDUMP_DISABLE_LOGIN_PATHS=true
  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/dbback_*.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'
  elif [[ $ALL_DATABASES_REQUESTED == true || $DATABASE_COUNT -eq 0 ]]; then
    printf 'ALL\n'
  else
    printf '%s\n' "${DATABASES[*]}"
  fi
}

connection_value() {
  local explicit=$1
  local value=$2

  if [[ -n $LOGIN_PATH && $explicit != true ]]; then
    printf 'profile-managed\n'
  else
    printf '%s\n' "$value"
  fi
}

database_connection_summary() {
  local source=arguments

  if [[ -n $LOGIN_PATH ]]; then
    source=login-path
  elif [[ -n $DEFAULTS_EXTRA_FILE ]]; then
    source=defaults-extra-file
  elif [[ $PASSWORD_CONFIGURED == true ]]; then
    source=password
  fi

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

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] MySQL 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
  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="dbback_$(date -u '+%Y%m%dT%H%M%SZ')_$$"
  STAGE_DIR=$(mktemp -d "$TARGET_DIR/.staging/${RUN_ID}.XXXXXX") || die "Cannot create staging directory"
  ERROR_LOG="$TARGET_DIR/.staging/${RUN_ID}.error.log"
  if ! (set -o noclobber; : > "$ERROR_LOG") 2>/dev/null; then
    die "Cannot create runtime error log without overwriting an existing path"
  fi
}

mysql_option_escape() {
  local value=$1

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

prepare_password_option_fd() {
  local escaped_password

  [[ $PASSWORD_CONFIGURED == true ]] || return 0
  escaped_password=$(mysql_option_escape "$DB_PASSWORD")
  exec 8< <(printf '[client]\npassword="%s"\n' "$escaped_password") || die "Cannot create anonymous MySQL credential stream"
}

close_password_option_fd() {
  [[ $PASSWORD_CONFIGURED == true ]] || return 0
  exec 8<&-
}

execute_hook_target() {
  local hook=$1
  local command=$2

  if [[ -n $hook ]]; then
    "$hook"
  else
    bash -c "$command"
  fi
}

run_hook() {
  local label=$1
  local hook=$2
  local command=$3

  [[ -n $hook || -n $command ]] || return 0
  if [[ -n $hook ]]; then
    log INFO "Running $label hook: $hook"
  else
    log INFO "Running $label hook command"
  fi
  if ! BACKUP_RUN_ID="$RUN_ID" \
    BACKUP_TARGET_DIR="$TARGET_DIR" \
    BACKUP_STAGE_DIR="$STAGE_DIR" \
    BACKUP_PUBLISHED_DIR="$PUBLISHED_DIR" \
    execute_hook_target "$hook" "$command" 2>"$ERROR_LOG"
  then
    fail_with_error_log "$label hook failed"
  fi
}

run_after_database_hook() {
  local database=$1
  local output_file=$2
  local index=$3
  local staged_file

  [[ -n $AFTER_DATABASE_HOOK || -n $AFTER_DATABASE_HOOK_COMMAND ]] || return 0
  CURRENT_PHASE=after-database-hook
  log INFO "Running after database hook for: $database"
  if ! 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="$output_file" \
    BACKUP_DATABASE_INDEX="$index" \
    BACKUP_DATABASE_TOTAL="$DATABASE_COUNT" \
    BACKUP_OUTPUT_MODE="$OUTPUT_MODE" \
    execute_hook_target "$AFTER_DATABASE_HOOK" "$AFTER_DATABASE_HOOK_COMMAND" 2>"$ERROR_LOG"
  then
    fail_with_error_log "After database hook failed for database: $database"
  fi
  for staged_file in "${SQL_FILES[@]}"; do
    if [[ ! -f $staged_file || -L $staged_file || ! -s $staged_file ]]; then
      die "After database hook removed or emptied the database dump: $(basename "$staged_file")"
    fi
  done
}

build_mysql_commands() {
  MYSQLDUMP_COMMAND=(mysqldump)
  MYSQL_COMMAND=(mysql)

  if [[ $PASSWORD_CONFIGURED == true ]]; then
    MYSQLDUMP_COMMAND+=("--defaults-file=/dev/fd/8")
    MYSQL_COMMAND+=("--defaults-file=/dev/fd/8")
    if [[ $MYSQLDUMP_DISABLE_LOGIN_PATHS == true ]]; then
      MYSQLDUMP_COMMAND+=(--no-login-paths)
      MYSQL_COMMAND+=(--no-login-paths)
    fi
  elif [[ -n $DEFAULTS_EXTRA_FILE ]]; then
    MYSQLDUMP_COMMAND+=("--defaults-extra-file=$DEFAULTS_EXTRA_FILE")
    MYSQL_COMMAND+=("--defaults-extra-file=$DEFAULTS_EXTRA_FILE")
  elif [[ -n $LOGIN_PATH ]]; then
    MYSQLDUMP_COMMAND+=("--login-path=$LOGIN_PATH")
    MYSQL_COMMAND+=("--login-path=$LOGIN_PATH")
  fi

  if [[ -z $LOGIN_PATH || $DB_HOST_EXPLICIT == true ]]; then
    MYSQLDUMP_COMMAND+=("--host=$DB_HOST")
    MYSQL_COMMAND+=("--host=$DB_HOST")
  fi
  if [[ -z $LOGIN_PATH || $DB_PORT_EXPLICIT == true ]]; then
    MYSQLDUMP_COMMAND+=("--port=$DB_PORT")
    MYSQL_COMMAND+=("--port=$DB_PORT")
  fi
  if [[ -z $LOGIN_PATH || $DB_USER_EXPLICIT == true ]]; then
    MYSQLDUMP_COMMAND+=("--user=$DB_USER")
    MYSQL_COMMAND+=("--user=$DB_USER")
  fi
  MYSQLDUMP_COMMAND+=("${DUMP_OPTIONS[@]}")
}

discover_databases() {
  local database
  local database_hex
  local database_list="$STAGE_DIR/.databases.list"
  local escaped_database
  local index
  local byte
  local query="SELECT HEX(SCHEMA_NAME) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('information_schema','performance_schema','ndbinfo') ORDER BY SCHEMA_NAME"

  prepare_password_option_fd
  build_mysql_commands
  if ! "${MYSQL_COMMAND[@]}" --batch --skip-column-names --execute="$query" >"$database_list" 2>"$ERROR_LOG"; then
    close_password_option_fd
    fail_with_error_log "mysql database discovery failed"
  fi
  close_password_option_fd

  DATABASES=()
  DATABASE_COUNT=0
  while IFS= read -r database_hex || [[ -n $database_hex ]]; do
    [[ $database_hex =~ ^([[:xdigit:]]{2})+$ ]] || die "mysql returned an invalid database name"
    escaped_database=
    for ((index = 0; index < ${#database_hex}; index += 2)); do
      byte=${database_hex:index:2}
      [[ $byte != 00 ]] || die "Discovered database names cannot contain control characters"
      escaped_database+="\\x$byte"
    done
    printf -v database '%b' "$escaped_database"
    case "$database" in
      information_schema|performance_schema|ndbinfo) continue ;;
    esac
    [[ $database != -* ]] || die "Discovered database names cannot start with '-'"
    ! value_has_control_characters "$database" || die "Discovered database names cannot contain control characters"
    DATABASES+=("$database")
    DATABASE_COUNT=$((DATABASE_COUNT + 1))
  done <"$database_list"
  rm -- "$database_list" || die "Cannot remove temporary database list"
  [[ $DATABASE_COUNT -gt 0 ]] || die "No databases are available to back up"
}

safe_database_slug() {
  local database=$1
  local slug

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

dump_to_file() {
  local output_file=$1
  shift

  prepare_password_option_fd
  build_mysql_commands
  if ! "${MYSQLDUMP_COMMAND[@]}" "$@" >"$output_file" 2>"$ERROR_LOG"; then
    close_password_option_fd
    fail_with_error_log "mysqldump failed"
  fi
  close_password_option_fd
  [[ -s $output_file ]] || die "mysqldump created an empty file: $(basename "$output_file")"
  SQL_FILES+=("$output_file")
}

dump_databases() {
  local index
  local database
  local slug
  local filename

  CURRENT_PHASE=dumping

  if [[ $DATABASE_COUNT -eq 0 && $SEPARATE_FILES == false ]]; then
    ALL_DATABASES_REQUESTED=true
    log INFO "Dumping all databases"
    dump_to_file "$STAGE_DIR/all_databases.sql" --all-databases
    return 0
  fi
  if [[ $DATABASE_COUNT -eq 0 ]]; then
    ALL_DATABASES_REQUESTED=true
    log INFO "Discovering databases for separate dumps"
    discover_databases
  fi

  if [[ $SEPARATE_FILES == true ]]; then
    index=0
    for database in "${DATABASES[@]}"; do
      index=$((index + 1))
      slug=$(safe_database_slug "$database")
      printf -v filename '%03d_%s.sql' "$index" "$slug"
      CURRENT_PHASE=dumping
      log INFO "Dumping database: $database"
      dump_to_file "$STAGE_DIR/$filename" --databases "$database"
      run_after_database_hook "$database" "$STAGE_DIR/$filename" "$index"
    done
  else
    log INFO "Dumping $DATABASE_COUNT selected database(s)"
    dump_to_file "$STAGE_DIR/selected_databases.sql" --databases "${DATABASES[@]}"
  fi
}

create_archive() {
  local -a sql_names=()
  local sql_file

  [[ $OUTPUT_MODE == zip || $OUTPUT_MODE == both ]] || return 0
  CURRENT_PHASE=compressing

  for sql_file in "${SQL_FILES[@]}"; do
    sql_names+=("$(basename "$sql_file")")
  done

  if ! (cd "$STAGE_DIR" && zip -q backup.zip.tmp "${sql_names[@]}"); then
    die "zip failed"
  fi
  mv -- "$STAGE_DIR/backup.zip.tmp" "$STAGE_DIR/backup.zip" || die "Cannot finalize backup.zip"
  zip -T "$STAGE_DIR/backup.zip" >/dev/null || die "backup.zip failed its integrity check"

  if [[ $OUTPUT_MODE == zip ]]; then
    rm -- "${SQL_FILES[@]}" || die "Cannot remove staged SQL files after compression"
  fi
}

write_manifest() {
  local end_epoch
  local database
  local artifact
  local connection_source=arguments
  local manifest_db_host
  local manifest_db_port
  local manifest_db_user

  CURRENT_PHASE=manifest
  end_epoch=$(date +%s)
  manifest_db_host=$(connection_value "$DB_HOST_EXPLICIT" "$DB_HOST")
  manifest_db_port=$(connection_value "$DB_PORT_EXPLICIT" "$DB_PORT")
  manifest_db_user=$(connection_value "$DB_USER_EXPLICIT" "$DB_USER")
  if [[ -n $LOGIN_PATH ]]; then
    connection_source=login-path
  elif [[ -n $DEFAULTS_EXTRA_FILE ]]; then
    connection_source=defaults-extra-file
  elif [[ $PASSWORD_CONFIGURED == true ]]; then
    connection_source=password
  fi

  {
    printf 'format_version=1\n'
    printf 'run_id=%s\n' "$RUN_ID"
  } > "$STAGE_DIR/.mysql-onekey-backup-run" || die "Cannot write ownership marker"

  {
    printf 'format_version=1\n'
    printf 'script_version=%s\n' "$SCRIPT_VERSION"
    printf 'run_id=%s\n' "$RUN_ID"
    printf 'server_name=%s\n' "$SERVER_NAME"
    printf 'connection_source=%s\n' "$connection_source"
    if [[ -n $LOGIN_PATH ]]; then
      printf 'login_path=%s\n' "$LOGIN_PATH"
    fi
    printf 'db_host=%s\n' "$manifest_db_host"
    printf 'db_port=%s\n' "$manifest_db_port"
    printf 'db_user=%s\n' "$manifest_db_user"
    printf 'output_mode=%s\n' "$OUTPUT_MODE"
    printf 'started_epoch=%s\n' "$START_EPOCH"
    printf 'finished_epoch=%s\n' "$end_epoch"
    printf 'elapsed_seconds=%s\n' "$((end_epoch - START_EPOCH))"
    if [[ $ALL_DATABASES_REQUESTED == true || $DATABASE_COUNT -eq 0 ]]; then
      printf 'database=ALL\n'
    else
      for database in "${DATABASES[@]}"; do
        printf 'database=%s\n' "$database"
      done
    fi
    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 '*.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 '*.sql' -o -name '*.zip' -o -name 'manifest.txt' -o -name '.mysql-onekey-backup-run' \) -print0)
  [[ ${#artifacts[@]} -gt 0 ]] || die "No backup artifacts were created"

  if command_exists sha256sum; then
    (cd "$STAGE_DIR" && sha256sum -- "${artifacts[@]}" > SHA256SUMS) || die "Cannot write SHA256SUMS"
    (cd "$STAGE_DIR" && sha256sum -c SHA256SUMS >/dev/null) || die "SHA256 verification failed"
  else
    (cd "$STAGE_DIR" && shasum -a 256 "${artifacts[@]}" > SHA256SUMS) || die "Cannot write SHA256SUMS"
    (cd "$STAGE_DIR" && shasum -a 256 -c SHA256SUMS >/dev/null) || die "SHA256 verification failed"
  fi
}

publish_run() {
  local candidate_dir

  CURRENT_PHASE=publishing
  candidate_dir="$TARGET_DIR/runs/$RUN_ID"
  [[ ! -e $candidate_dir ]] || die "Backup run already exists: $candidate_dir"
  mv -- "$STAGE_DIR" "$candidate_dir" || die "Cannot publish backup run"
  PUBLISHED_DIR=$candidate_dir
  STAGE_DIR=
}

identity_file_matches() {
  local path=$1
  local expected_run_id=$2
  local line
  local has_format=false
  local has_run_id=false

  [[ -f $path && ! -L $path ]] || return 1
  while IFS= read -r line || [[ -n $line ]]; do
    case "$line" in
      format_version=1)
        [[ $has_format == false ]] || return 1
        has_format=true
        ;;
      format_version=*) return 1 ;;
      "run_id=$expected_run_id")
        [[ $has_run_id == false ]] || return 1
        has_run_id=true
        ;;
      run_id=*) return 1 ;;
    esac
  done < "$path"
  [[ $has_format == true && $has_run_id == true ]]
}

managed_run_is_valid() {
  local run_dir=$1
  local run_id

  [[ -d $run_dir && ! -L $run_dir ]] || return 1
  run_id=$(basename "$run_dir")
  identity_file_matches "$run_dir/.mysql-onekey-backup-run" "$run_id" || return 1
  identity_file_matches "$run_dir/manifest.txt" "$run_id"
}

prune_expired_runs() {
  local expire_minutes
  local expired_run

  [[ $EXPIRE_HOURS -gt 0 ]] || return 0
  CURRENT_PHASE=retention
  expire_minutes=$((EXPIRE_HOURS * 60))

  while IFS= read -r -d '' expired_run; do
    case "$expired_run" in
      "$TARGET_DIR"/runs/dbback_*)
        if managed_run_is_valid "$expired_run"; then
          log INFO "Removing expired backup run: $expired_run"
          rm -rf -- "$expired_run" || die "Cannot remove expired backup run: $expired_run"
        else
          log WARN "Skipping unrecognized backup directory: $expired_run"
        fi
        ;;
      *)
        die "Refusing to remove unexpected retention path: $expired_run"
        ;;
    esac
  done < <(find "$TARGET_DIR/runs" -mindepth 1 -maxdepth 1 -type d -name 'dbback_*' -mmin "+$expire_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

  if [[ $NOTIFY_START == true ]]; then
    notify_event start "Backup started" || true
  fi

  CURRENT_PHASE=before-hook
  run_hook "Before" "$BEFORE_HOOK" "$BEFORE_HOOK_COMMAND"
  dump_databases
  create_archive
  write_manifest
  write_and_verify_checksums
  publish_run
  prune_expired_runs

  CURRENT_PHASE=after-hook
  run_hook "After" "$AFTER_HOOK" "$AFTER_HOOK_COMMAND"

  CURRENT_PHASE=complete
  end_epoch=$(date +%s)
  FINAL_MESSAGE="Backup completed: $PUBLISHED_DIR ($((end_epoch - START_EPOCH))s)"
  log INFO "$FINAL_MESSAGE"
}

main() {
  load_environment_lists
  parse_args "$@"
  if [[ $DEPENDENCY_REPORT == true ]]; then
    case "$OUTPUT_MODE" in
      zip|sql|both) ;;
      *) die "--output-mode must be zip, sql, 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 "$@"
