#!/bin/sh
#
# borg-apiscp-repo: manage the Borg repository the engine writes to.
#
# Usage:
#   borg-apiscp-repo status
#       Show repository info (borg info).
#
#   borg-apiscp-repo init
#       Create (initialise) the repository at BORG_REPO with BORG_ENCRYPTION
#       (default repokey-blake2). Needs a passphrase (see gen-passphrase). Safe
#       to run once; refuses if the repository already exists.
#
#   borg-apiscp-repo check-access
#       Confirm the repository is reachable and the passphrase is correct
#       (prints "ok" or an error); always exits 0 so a UI can read the message.
#
#   borg-apiscp-repo gen-passphrase
#       Generate a strong random passphrase, store it as BORG_PASSPHRASE, and
#       print ONLY the passphrase to stdout (so a UI can show it once).
#
#   borg-apiscp-repo key-status
#       Print "yes" if the one-time key/passphrase backup has been done, else "no".
#
#   borg-apiscp-repo backup-key [DEST] [--force]
#       Export the passphrase and `borg key export` to DEST (default
#       KEY_BACKUP_DIR), 0600, once. Store DEST off this machine.
#
#   borg-apiscp-repo key-bundle
#       Print a self-contained recovery bundle (instructions + passphrase +
#       exported key + repo location) to stdout for the one-time GUI display.
#       Contains secrets by design.
#
#   borg-apiscp-repo config-get | config-set KEY VALUE
#       Read / write whitelisted keys in the config file.
#
#   borg-apiscp-repo sites
#       List "siteN<TAB>domain" for every ApisCP site.
#
#   borg-apiscp-repo run [--site SITE | --path PATH]
#       No args: start the scheduled backup service (detached). --site/--path:
#       a detached one-shot scoped to one site or one extra absolute path.
#
#   borg-apiscp-repo running
#       Print the backup service state (active/inactive/...).
#
#   borg-apiscp-repo prune
#       Apply retention (borg prune) across all archive prefixes per config.
#
#   borg-apiscp-repo maintenance
#       Run `borg compact` (reclaim space), detached.
#
#   borg-apiscp-repo verify [--data]
#       Run `borg check` (repository + archive consistency), detached. --data
#       also reads file contents back (slow, thorough).
#
#   borg-apiscp-repo set-schedule [SPEC]
#       Set the backup timer's OnCalendar (HH:MM daily or an OnCalendar
#       expression); falls back to BACKUP_SCHEDULE. Writes a timer drop-in.
#
#   borg-apiscp-repo notify-test
#       Send a test notification email to NOTIFY_EMAIL.
#
#   borg-apiscp-repo list-databases SITE
#       List "<engine>\t<db>" for every database ApisCP knows for SITE.

set -u

_self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
for _cand in \
	"${APISCP_BORG_LIB:-}" \
	"$_self_dir/../lib/apiscp-borg-common.sh" \
	/usr/local/lib/apiscp-borg/apiscp-borg-common.sh \
	/usr/lib/apiscp-borg/apiscp-borg-common.sh; do
	[ -n "$_cand" ] && [ -r "$_cand" ] && { . "$_cand"; _lib_loaded=1; break; }
done
[ "${_lib_loaded:-0}" = 1 ] || { echo "FATAL: cannot find apiscp-borg-common.sh" >&2; exit 1; }

CONFIG_FILE="${APISCP_BORG_CONFIG:-/etc/apiscp-borg/config}"
# shellcheck source=/dev/null
[ -r "$CONFIG_FILE" ] && . "$CONFIG_FILE"
: "${BORG_REPO:=}"
: "${BORG_ENCRYPTION:=repokey-blake2}"
: "${BORG_RSH:=ssh -o BatchMode=yes}"
: "${KEY_BACKUP_DIR:=/root/apiscp-borg-keys}"
: "${VIRTBASE:=/home/virtual}"
: "${APNSCP_CMD:=/usr/local/apnscp/bin/cmd}"
export BORG_REPO BORG_RSH
[ -n "${BORG_PASSPHRASE:-}" ] && export BORG_PASSPHRASE

usage() { awk 'NR>=2 && /^set -u/{exit} NR>=2{sub(/^# ?/,""); print}' "$0"; exit "${1:-2}"; }

# Config keys the GUI/CLI may set. This list is the security boundary.
CONFIG_ALLOWED="BORG_REPO BORG_PASSPHRASE BORG_ENCRYPTION BORG_RSH BORG_COMPRESSION KEY_BACKUP_DIR VIRTBASE METRICS_DIR RUN_DB_EXPORT BACKUP_SITES BACKUP_SYSTEM BACKUP_PATHS BACKUP_DATABASES BACKUP_EXCLUDES BACKUP_SCHEDULE CAPTURE_META_SIDECAR RETENTION_KEEP_WITHIN RETENTION_KEEP_DAILY RETENTION_KEEP_WEEKLY RETENTION_KEEP_MONTHLY RETENTION_KEEP_ANNUAL NOTIFY_EMAIL NOTIFY_ON NOTIFY_FROM"

_need_repo() { [ -n "$BORG_REPO" ] || akp_die "BORG_REPO is not set (config-set BORG_REPO ... first)"; }

cmd_config_get() {
	[ -r "$CONFIG_FILE" ] || return 0
	while IFS= read -r _line; do
		case "$_line" in ''|\#*) continue ;; esac
		case "$_line" in *=*) ;; *) continue ;; esac
		_k=$(printf '%s' "${_line%%=*}" | tr -d '[:space:]')
		_v=${_line#*=}
		_v=$(printf '%s' "$_v" | sed -e "s/^'//" -e "s/'\$//" -e 's/^"//' -e 's/"$//')
		printf '%s=%s\n' "$_k" "$_v"
	done < "$CONFIG_FILE"
}

cmd_config_set() {
	_k="${1:-}"; _v="${2:-}"
	[ -n "$_k" ] || akp_die "config-set: key required"
	case " $CONFIG_ALLOWED " in *" $_k "*) ;; *) akp_die "config-set: refusing unknown key '$_k'" ;; esac
	if [ "$_v" != "$(printf '%s' "$_v" | tr -d '\r\n')" ]; then
		akp_die "config-set: value may not contain newlines"
	fi
	_esc=$(printf '%s' "$_v" | sed "s/'/'\\\\''/g")
	mkdir -p "$(dirname "$CONFIG_FILE")"
	_tmp=$(mktemp); _found=0
	if [ -f "$CONFIG_FILE" ]; then
		while IFS= read -r _line; do
			case "$_line" in
				"$_k="*|"$_k ="*) printf "%s='%s'\n" "$_k" "$_esc" >> "$_tmp"; _found=1 ;;
				*) printf '%s\n' "$_line" >> "$_tmp" ;;
			esac
		done < "$CONFIG_FILE"
	fi
	[ "$_found" = 1 ] || printf "%s='%s'\n" "$_k" "$_esc" >> "$_tmp"
	install -m 0600 "$_tmp" "$CONFIG_FILE"; rm -f "$_tmp"
}

cmd_sites() {
	for _d in "$VIRTBASE"/site[0-9]*; do
		[ -d "$_d" ] || continue
		_s=$(basename "$_d")
		_dom=$(akp_site_domain "$_s")
		printf '%s\t%s\n' "$_s" "${_dom:-$_s}"
	done
}

cmd_status() { _need_repo; echo "repository: $BORG_REPO"; akp_borg info 2>&1 | sed 's/^/  /' | head -30; }

cmd_check_access() {
	_need_repo
	if akp_borg info >/dev/null 2>&1; then echo ok; else echo "error: cannot access $BORG_REPO (check passphrase/connectivity)"; fi
	return 0
}

cmd_init() {
	_need_repo
	[ -n "${BORG_PASSPHRASE:-}" ] || akp_die "no passphrase set; run: borg-apiscp-repo gen-passphrase first"
	if akp_borg info >/dev/null 2>&1; then
		akp_log "repository already exists at $BORG_REPO; nothing to do"
		return 0
	fi
	akp_log "initialising repository at $BORG_REPO ($BORG_ENCRYPTION)"
	akp_borg init --encryption "$BORG_ENCRYPTION" || akp_die "init failed"
	akp_log "repository initialised. Now back up the key: borg-apiscp-repo backup-key"
}

cmd_gen_passphrase() {
	if command -v openssl >/dev/null 2>&1; then _pw=$(openssl rand -base64 30); else _pw=$(head -c 30 /dev/urandom | base64); fi
	_pw=$(printf '%s' "$_pw" | tr -d '\r\n')
	[ -n "$_pw" ] || akp_die "gen-passphrase: failed to generate"
	cmd_config_set BORG_PASSPHRASE "$_pw"
	printf '%s\n' "$_pw"
}

cmd_key_status() { [ -e "$KEY_BACKUP_DIR/.apiscp-borg-key-backed-up" ] && echo yes || echo no; }

cmd_backup_key() {
	_dest="$KEY_BACKUP_DIR"; _force=0
	for a in "$@"; do case "$a" in --force) _force=1 ;; -*) usage 2 ;; *) _dest="$a" ;; esac; done
	_need_repo
	_sentinel="$_dest/.apiscp-borg-key-backed-up"
	if [ -e "$_sentinel" ] && [ "$_force" != 1 ]; then
		akp_log "key already backed up at $_dest ($(cat "$_sentinel")). Use --force to redo."; return 0
	fi
	mkdir -p "$_dest" && chmod 0700 "$_dest" || akp_die "cannot create $_dest"
	if [ -n "${BORG_PASSPHRASE:-}" ]; then
		umask 077; printf '%s\n' "$BORG_PASSPHRASE" > "$_dest/repository.passphrase"; chmod 0600 "$_dest/repository.passphrase"
	else
		akp_warn "no BORG_PASSPHRASE in config; store the passphrase separately"
	fi
	akp_borg key export :: "$_dest/repository.key" 2>/dev/null || akp_warn "borg key export failed (repokey repos keep the key in the repo)"
	[ -f "$_dest/repository.key" ] && chmod 0600 "$_dest/repository.key"
	cat > "$_dest/README.txt" <<EOF
apiscp-borg repository key backup
=================================
The ONLY way to recover this encrypted Borg repository if the server is lost.
Copy this directory OFF the machine and keep it safe.

Files:
  repository.passphrase   the Borg passphrase (guard this)
  repository.key          exported key material (borg key import)

Recover on a fresh machine:
  1. install borgbackup
  2. export BORG_PASSPHRASE=\$(cat repository.passphrase)
  3. export BORG_REPO=<the repository location>
  4. borg key import :: repository.key   # only needed for keyfile-mode repos
  5. borg list
EOF
	chmod 0600 "$_dest/README.txt"
	date '+%Y-%m-%dT%H:%M:%S%z' > "$_sentinel" 2>/dev/null || echo done > "$_sentinel"
	akp_log "repository key backed up to $_dest"
	echo "IMPORTANT: copy $_dest to secure OFF-MACHINE storage now." >&2
}

cmd_key_bundle() {
	_need_repo
	echo "apiscp-borg repository recovery bundle"
	echo "======================================"
	echo "KEEP THIS SAFE AND OFF THE SERVER. If this passphrase/key is lost, the"
	echo "encrypted backups are PERMANENTLY IRRECOVERABLE."
	echo
	echo "Repository: $BORG_REPO"
	echo
	echo "== passphrase =="
	printf '%s\n' "${BORG_PASSPHRASE:-}"
	echo
	echo "== exported key (borg key import :: <file>; needed for keyfile-mode) =="
	akp_borg key export :: /dev/stdout 2>/dev/null || echo "(key export unavailable)"
}

# run: no args starts the scheduled service; --site/--path a detached one-shot.
cmd_run() {
	case "${1:-}" in
		""|--all) systemctl start --no-block borg-apiscp-backup.service ;;
		--site)
			_s="${2:-}"; case "$_s" in site[0-9]*|none) ;; *) akp_die "run --site: invalid site '$_s'" ;; esac
			_run_oneshot BACKUP_SITES="$_s" BACKUP_SYSTEM=0 ;;
		--path)
			_p="${2:-}"; [ -n "$_p" ] || akp_die "run --path: path required"
			case "$_p" in /*) ;; *) akp_die "run --path: must be absolute" ;; esac
			{ [ -e "$_p" ] && [ -r "$_p" ]; } || akp_die "run --path: not readable: $_p"
			_run_oneshot BACKUP_SITES=none BACKUP_SYSTEM=0 BACKUP_PATHS="$_p" ;;
		*) akp_die "run: unknown option '$1'" ;;
	esac
}

_run_oneshot() {
	_unit="apiscp-borg-manual-$(date +%s)"
	if command -v systemd-run >/dev/null 2>&1; then
		_sr="systemd-run --collect --unit=$_unit"
		for _kv in "$@"; do _sr="$_sr --setenv=$_kv"; done
		$_sr /usr/local/bin/borg-apiscp-backup
	else
		env "$@" /usr/local/bin/borg-apiscp-backup >/dev/null 2>&1 &
		akp_log "started detached backup (pid $!)"
	fi
}

cmd_running() { systemctl is-active borg-apiscp-backup.service 2>/dev/null || true; }

# prune: apply retention across every archive prefix per config.
cmd_prune() {
	_need_repo
	set --
	[ -n "${RETENTION_KEEP_WITHIN:-}" ]  && set -- "$@" --keep-within "$RETENTION_KEEP_WITHIN"
	[ -n "${RETENTION_KEEP_DAILY:-}" ]   && set -- "$@" --keep-daily "$RETENTION_KEEP_DAILY"
	[ -n "${RETENTION_KEEP_WEEKLY:-}" ]  && set -- "$@" --keep-weekly "$RETENTION_KEEP_WEEKLY"
	[ -n "${RETENTION_KEEP_MONTHLY:-}" ] && set -- "$@" --keep-monthly "$RETENTION_KEEP_MONTHLY"
	[ -n "${RETENTION_KEEP_ANNUAL:-}" ]  && set -- "$@" --keep-yearly "$RETENTION_KEEP_ANNUAL"
	[ "$#" -ge 1 ] || { akp_log "prune: no retention configured; nothing to do"; return 0; }
	# Prune per prefix so one site's churn never expires another's history.
	for _d in "$VIRTBASE"/site[0-9]*; do
		[ -d "$_d" ] || continue
		_s=$(basename "$_d")
		for _sub in shadow info db; do
			akp_borg prune --glob-archives "$_s-$_sub-*" "$@" >/dev/null 2>&1 && akp_log "pruned $_s-$_sub-*" || akp_warn "prune failed for $_s-$_sub-*"
		done
	done
	akp_borg prune --glob-archives "_system-*" "$@" >/dev/null 2>&1 || true
	akp_borg prune --glob-archives "_custom-*" "$@" >/dev/null 2>&1 || true
}

cmd_maintenance() {
	_need_repo
	_unit="apiscp-borg-compact-$(date +%s)"
	if command -v systemd-run >/dev/null 2>&1; then
		systemd-run --collect --unit="$_unit" --setenv=BORG_REPO="$BORG_REPO" ${BORG_PASSPHRASE:+--setenv=BORG_PASSPHRASE="$BORG_PASSPHRASE"} --setenv=BORG_RSH="$BORG_RSH" borg compact
		akp_log "started borg compact ($_unit)"
	else
		akp_borg compact >/dev/null 2>&1 & akp_log "started borg compact (pid $!)"
	fi
}

cmd_verify() {
	_need_repo
	_data=""; [ "${1:-}" = "--data" ] && _data="--verify-data"
	_unit="apiscp-borg-check-$(date +%s)"
	if command -v systemd-run >/dev/null 2>&1; then
		systemd-run --collect --unit="$_unit" --setenv=BORG_REPO="$BORG_REPO" ${BORG_PASSPHRASE:+--setenv=BORG_PASSPHRASE="$BORG_PASSPHRASE"} --setenv=BORG_RSH="$BORG_RSH" borg check $_data
		akp_log "started borg check ${_data:-(consistency)} ($_unit)"
	else
		akp_borg check $_data >/dev/null 2>&1 & akp_log "started borg check (pid $!)"
	fi
}

cmd_set_schedule() {
	_spec="${1:-${BACKUP_SCHEDULE:-}}"
	[ -n "$_spec" ] || akp_die "set-schedule: no schedule given (HH:MM or an OnCalendar expression)"
	case "$_spec" in
		[0-9][0-9]:[0-9][0-9])
			_h=${_spec%%:*}; _m=${_spec#*:}
			{ [ "$_h" -ge 0 ] && [ "$_h" -le 23 ] && [ "$_m" -ge 0 ] && [ "$_m" -le 59 ]; } 2>/dev/null || akp_die "set-schedule: invalid time '$_spec'"
			_cal="*-*-* $_spec:00" ;;
		*) case "$_spec" in *[!0-9A-Za-z:*\ ,/-]*) akp_die "set-schedule: invalid schedule '$_spec'" ;; esac; _cal="$_spec" ;;
	esac
	_dir=/etc/systemd/system/borg-apiscp-backup.timer.d
	mkdir -p "$_dir"; _tmp=$(mktemp)
	printf '[Timer]\n# Managed by apiscp-borg (panel / BACKUP_SCHEDULE). Do not edit by hand.\nOnCalendar=\nOnCalendar=%s\n' "$_cal" > "$_tmp"
	install -m 0644 "$_tmp" "$_dir/override.conf"; rm -f "$_tmp"
	systemctl daemon-reload; systemctl restart borg-apiscp-backup.timer 2>/dev/null || true
	akp_log "backup schedule set to OnCalendar='$_cal'"; printf '%s\n' "$_cal"
}

cmd_notify_test() {
	[ -n "${NOTIFY_EMAIL:-}" ] || akp_die "notify-test: set NOTIFY_EMAIL first"
	_h=$(hostname 2>/dev/null || echo apiscp)
	akp_send_mail "$NOTIFY_EMAIL" "[apiscp-borg] test notification from $_h" \
		"This is a test email from apiscp-borg on $_h. If you received it, backup notifications are configured correctly." \
		"${NOTIFY_FROM:-}" || akp_die "notify-test: send failed (check the mail system)"
	akp_log "sent test notification to $NOTIFY_EMAIL"; printf 'sent to %s\n' "$NOTIFY_EMAIL"
}

cmd_list_databases() {
	_site="${1:-}"
	case "$_site" in site[0-9]*) ;; *) akp_die "list-databases: invalid site '$_site'" ;; esac
	_dom=$(akp_site_domain "$_site"); [ -n "$_dom" ] || akp_die "list-databases: no domain for $_site"
	for _eng in mysql pgsql; do
		_json=$("$APNSCP_CMD" -o json -d "$_dom" "$_eng:list_databases" 2>/dev/null) || continue
		[ -n "$_json" ] || continue
		printf '%s\n' "$_json" | grep -oE '"[A-Za-z0-9_-]+"' | sed 's/"//g' | while IFS= read -r _db; do
			[ -n "$_db" ] && printf '%s\t%s\n' "$_eng" "$_db"
		done
	done
}

case "${1:-}" in
	status)         shift; cmd_status "$@" ;;
	init)           shift; cmd_init "$@" ;;
	check-access)   shift; cmd_check_access "$@" ;;
	gen-passphrase) shift; cmd_gen_passphrase "$@" ;;
	key-status)     shift; cmd_key_status "$@" ;;
	backup-key)     shift; cmd_backup_key "$@" ;;
	key-bundle)     shift; cmd_key_bundle "$@" ;;
	config-get)     shift; cmd_config_get "$@" ;;
	config-set)     shift; cmd_config_set "$@" ;;
	sites)          shift; cmd_sites "$@" ;;
	run)            shift; cmd_run "$@" ;;
	running)        shift; cmd_running "$@" ;;
	prune)          shift; cmd_prune "$@" ;;
	maintenance)    shift; cmd_maintenance "$@" ;;
	verify)         shift; cmd_verify "$@" ;;
	set-schedule)   shift; cmd_set_schedule "$@" ;;
	notify-test)    shift; cmd_notify_test "$@" ;;
	list-databases) shift; cmd_list_databases "$@" ;;
	-h|--help|help|"") usage 0 ;;
	*) echo "unknown command: $1" >&2; usage 2 ;;
esac
