#!/bin/sh
# wpchroot -- WordPress sites that run in chrooted php-fpm pools (OpenBSD, wp-cli): backup,
# restore, migrate, info, sync of the shipped mu-plugins and drop-ins, update, perms.
# Runs as WP_USER (doas -u WP_USER wpchroot ...), except `perms`, which is root's.
#
#   info    [<site|backup>]       live descriptor of a site OR manifest of a backup; lists sites
#   backup                         lists the WP sites
#   backup  <site>                 backup -> BACKUP_ROOT/<ts>_<site>/
#   backup  --prune [<site>]       removes the backups beyond BACKUP_KEEP, newest first kept
#   restore                        lists the backups (directory names)
#   restore <backup> <target>      restores a backup onto <target>  (OVERWRITES the target)
#   migrate <source> <target>      backup source + backup target + restore source->target
#   sync    [-n] [-f name]... [<site|docroot>...]   copies the shipped mu-plugins and drop-ins
#   update  [-c] <site|docroot>... themes + plugins + translations, or the core (-c)
#   perms   <docroot> <owner> <group> <dir_mode> <file_mode>   root only, wpchroot-perms
#
# The backup is self-describing (manifest): restore adapts it to any target (search-replace of
# the original URL, table prefix carried over), while preserving the wp-config.php of the target
# so its DB credentials stay its own.
#
# Exit codes: 0 done, 1 failure, 2 usage or refused argument, 3 site locked by another run,
# 4 bad configuration; `sync -n`: 0 nothing differs, else 10 + the number of differing files,
# at most 110. See wpchroot(8).

set -eu
( set -o pipefail ) 2>/dev/null && set -o pipefail || true
# ksh imports the caller's PATH over its default and never restores it
# [verifie:openbsd/src bin/ksh/main.c:272-274].
PATH=/bin:/sbin:/usr/bin:/usr/sbin

# Fixed by the package, not by the site: the configuration file, the numeric uid and gid it must
# have, the tree the package installs, the doas boundary, the root helper, syslog.
CONF=/etc/wpchroot.conf
CONF_IDS='0 0'
SHARE=/usr/local/share/wpchroot
OWNER=/usr/local/bin/wpchroot-owner
PERMS=/usr/local/sbin/wpchroot-perms
LOGGER=/usr/bin/logger

ME=wpchroot
ASSUME_YES=0
LOCKED=''
SITE=''
DOC=''

die()  { echo "$ME: $*" >&2; exit 1; }
warn() { echo "$ME: $*" >&2; }
usage_err() { echo "$ME: $* (see wpchroot(8))" >&2; exit 2; }

# The exit status of a command that loops over sites is the FIRST failure's: the code of the
# first site that failed (2 refused, 3 locked, 1 failed) is kept, later ones never replace it.
# rc is the variable of the loop in progress, set to 0 before it.
note_rc() { if [ "$rc" = 0 ]; then rc=$1; fi; return 0; }

# One line to syslog per mutating action; a missing logger never fails the action.
log_action() { "$LOGGER" -t wpchroot -- "$*" 2>/dev/null || true; }

usage() {
	cat >&2 <<EOF
usage: $ME [-y] <subcommand> ...
  info    [<site|backup>]        live descriptor of the site, or manifest of the backup;
                                 no argument: lists the WP sites
  backup                         lists the WP sites
  backup  <site>                 backup -> BACKUP_ROOT/<ts>_<site>/
  backup  --prune [<site>]       removes the backups beyond BACKUP_KEEP per site
  restore                        lists the backups
  restore <backup> <target>      restores a backup onto <target> (OVERWRITES)
  migrate <source> <target>      backup source + backup target + restore
  sync    [-n] [-f name]... [<site|docroot>...]
                                 copies the shipped mu-plugins and drop-ins into the sites
  update  [-c] <site|docroot>... updates themes, plugins and translations (-c: the core)
  perms   <docroot> <owner> <group> <dir_mode> <file_mode>   as root
  -y                             do not ask for confirmation
EOF
}

# ---- configuration ----------------------------------------------------------------------

conf_fail() { echo "$ME: $CONF: $1" >&2; exit 4; }

# The file must be root's and closed to group and others before any byte of it is used, then
# every line must be a comment, a blank, or KEY=value of a known key with portable characters
# only: nothing the shell could expand or run when the file is sourced. ls -ldn: "-" first for a
# regular file (a link shows "l"), the numeric uid and gid; mode character 6 is group write, 9 is
# other write.
conf_load() {
	[ -f "$CONF" ] || conf_fail "not found"
	# shellcheck disable=SC2046
	set -- $(ls -ldn "$CONF")
	case $1 in -*) ;; *) conf_fail "not a regular file" ;; esac
	[ "$3 $4" = "$CONF_IDS" ] || conf_fail "must be owned by root:wheel"
	case $1 in -????[!w]??[!w]*) ;; *) conf_fail "writable by group or others" ;; esac

	_n=0
	while IFS= read -r _l || [ -n "$_l" ]; do
		_n=$((_n + 1))
		case $_l in ''|'#'*) continue ;; esac
		case $_l in
			[A-Z]*=*) ;;
			*) conf_fail "line $_n: not KEY=value" ;;
		esac
		_k=${_l%%=*}
		_v=${_l#*=}
		case $_k in
			WWW|SITES_ROOT|HTDOCS_SHARED|WPCLI_HOME|BACKUP_ROOT|WP|GTAR|WP_USER|WWW_USER) ;;
			CODE_DIR_MODE|CODE_FILE_MODE|BACKUP_KEEP) ;;
			*) conf_fail "line $_n: unknown key $_k" ;;
		esac
		case $_v in
			\"*\") _v=${_v#\"}; _v=${_v%\"} ;;
			\'*\') _v=${_v#\'}; _v=${_v%\'} ;;
		esac
		case $_v in
			*[!A-Za-z0-9._/+:@%-]*) conf_fail "line $_n: unsafe character in the value of $_k" ;;
		esac
	done < "$CONF"

	WWW=''; SITES_ROOT=''; HTDOCS_SHARED=''; WPCLI_HOME=''; BACKUP_ROOT=''; WP=''; GTAR=''
	WP_USER=''; WWW_USER=''; CODE_DIR_MODE=''; CODE_FILE_MODE=''; BACKUP_KEEP=''
	# shellcheck source=/dev/null
	. "$CONF"

	for _k in WWW SITES_ROOT HTDOCS_SHARED WPCLI_HOME BACKUP_ROOT WP GTAR; do
		eval "_v=\$$_k"
		[ -n "$_v" ] || conf_fail "$_k is not set"
		case $_v in
			/*) ;;
			*) conf_fail "$_k is not an absolute path" ;;
		esac
		case $_v in
			*/|*//*|*/../*|*/..|*/./*|*/.) conf_fail "$_k is not a plain path" ;;
		esac
	done
	for _k in WP_USER WWW_USER; do
		eval "_v=\$$_k"
		case $_v in
			''|-*|.*) conf_fail "$_k is not set or not an account name" ;;
		esac
	done
	[ "$WP_USER" != "$WWW_USER" ] || conf_fail "WP_USER and WWW_USER are the same account"
	for _k in CODE_DIR_MODE CODE_FILE_MODE; do
		eval "_v=\$$_k"
		case $_v in
			[0-7][0-7][0-7]|[0-7][0-7][0-7][0-7]) ;;
			*) conf_fail "$_k is not an octal mode" ;;
		esac
	done
	# Empty: keep every backup. Otherwise a positive count: 0 would remove the newest.
	case $BACKUP_KEEP in
		'') ;;
		*[!0-9]*|0*) conf_fail "BACKUP_KEEP is not a positive integer" ;;
	esac
	# The root helpers know WWW alone and derive the two site trees from it.
	[ "$SITES_ROOT" = "$WWW/sites" ] || conf_fail "SITES_ROOT must be \$WWW/sites"
	[ "$HTDOCS_SHARED" = "$WWW/htdocs" ] || conf_fail "HTDOCS_SHARED must be \$WWW/htdocs"
}

need_wp_user() {
	[ "$(id -un)" = "$WP_USER" ] || die "must run as $WP_USER (e.g. doas -u $WP_USER $ME ...)"
}

# ---- sites ------------------------------------------------------------------------------

host_ok() {
	case $1 in
		''|.*|-*|*[!A-Za-z0-9.-]*) return 1 ;;
	esac
	return 0
}

# resolve <site|docroot>: SITE and DOC, or a message and rc 2 (an argument refused) or 1 (no such
# site). A site name finds its tree under SITES_ROOT first, then HTDOCS_SHARED; a path must be
# exactly one of the two docroot forms.
resolve() {
	case $1 in
		*/*)
			case $1 in
				"$SITES_ROOT"/*/htdocs)
					_t=${1#"$SITES_ROOT"/}
					SITE=${_t%/htdocs}
					;;
				"$HTDOCS_SHARED"/*) SITE=${1#"$HTDOCS_SHARED"/} ;;
				*) warn "refused docroot $1: outside $SITES_ROOT/<site>/htdocs and $HTDOCS_SHARED/<site>"; return 2 ;;
			esac
			host_ok "$SITE" || { warn "refused docroot $1: site is not a host name"; return 2; }
			DOC=$1
			;;
		*)
			host_ok "$1" || { warn "not a site name: $1"; return 2; }
			SITE=$1
			if [ -d "$SITES_ROOT/$SITE" ]; then
				DOC=$SITES_ROOT/$SITE/htdocs
			elif [ -d "$HTDOCS_SHARED/$SITE" ]; then
				DOC=$HTDOCS_SHARED/$SITE
			else
				warn "unknown site: $1"
				return 1
			fi
			;;
	esac
	return 0
}

# wp-config lives in htdocs OR in the parent (chroot root, after migration).
is_wp_site() {
	resolve "$1" 2>/dev/null || return 1
	[ -f "$DOC/wp-config.php" ] && return 0
	case $DOC in
		"$SITES_ROOT"/*) [ -f "${DOC%/*}/wp-config.php" ] ;;
		*) return 1 ;;
	esac
}

list_sites() {
	for d in "$SITES_ROOT"/*/; do
		[ -d "$d" ] || continue
		[ -f "$d/wp-config.php" ] || [ -f "$d/htdocs/wp-config.php" ] || continue
		basename "$d"
	done
	for d in "$HTDOCS_SHARED"/*/; do
		[ -d "$d" ] || continue
		[ -f "$d/wp-config.php" ] || continue
		basename "$d"
	done
}

# ---- one run per site -------------------------------------------------------------------

# A directory per site under WPCLI_HOME/lock, taken by mkdir: the creation is atomic and refuses
# to wait, so nothing here sleeps. The owner writes its pid with a rename, so a reader never
# sees an empty pid file. A lock with no pid yet is a run still starting, left alone for a minute.
# A pid reused by another process makes a dead lock look held: remove WPCLI_HOME/lock/<site> by
# hand. Returns 0 taken, 3 held (message on stderr).
#
# Taking over a dead lock. Renaming the directory away is atomic, but judging the pid dead and
# renaming are two steps: in between, another run can take the lock over and write its own pid, and
# the rename would then move ITS live lock. The two steps are serialized by a second mkdir mutex
# per site, WPCLI_HOME/lock/.reap.<site>, held only around judge-again + rename (microseconds): a
# run that finds it held does not wait, it returns 3 at once, as for any held lock. Inside the
# mutex the lock is judged again, and only a lock still dead is moved. No other run can move or
# create the lock directory while it exists, since a new one needs mkdir to succeed on an absent
# name and a removal needs the mutex, and the owner of a dead lock removes nothing. Chosen over
# re-reading the pid inside the moved directory: that one has to put a live lock back, and between
# the move and the put-back the site looks free. A reaper killed inside the mutex leaves it behind:
# a mutex older than a minute is dead (it is held for microseconds) and is renamed away in turn;
# the double fault of two runs doing that at once with a third reaping live can still move a live
# mutex, which is put back when its age shows it, best effort.
lock_held() { warn "site $1 is locked by another run (pid $2)"; return 3; }

# lock_stale DIR: 0 when the lock is abandoned (owner dead, or no pid for more than a minute), else
# 1 with _p the pid, or "starting".
lock_stale() {
	_p=''
	if [ -f "$1/pid" ]; then
		read -r _p < "$1/pid" || _p=''
	fi
	case $_p in
		''|*[!0-9]*)
			if [ -z "$(find "$1" -prune -mmin +1 2>/dev/null)" ]; then
				_p=starting
				return 1
			fi
			return 0
			;;
	esac
	if kill -0 "$_p" 2>/dev/null; then return 1; fi
	return 0
}

REAPING=''
reap_enter() {   # $1 = the mutex directory
	if mkdir "$1" 2>/dev/null; then REAPING=$1; return 0; fi
	if [ -n "$(find "$1" -prune -mmin +1 2>/dev/null)" ] && mv "$1" "$1.stale.$$" 2>/dev/null; then
		if [ -n "$(find "$1.stale.$$" -prune -mmin +1 2>/dev/null)" ]; then
			rm -rf "$1.stale.$$"
			if mkdir "$1" 2>/dev/null; then REAPING=$1; return 0; fi
		else
			mv "$1.stale.$$" "$1" 2>/dev/null || rm -rf "$1.stale.$$"
		fi
	fi
	return 1
}
reap_leave() {
	if [ -n "$REAPING" ]; then rmdir "$REAPING" 2>/dev/null || true; fi
	REAPING=''
	return 0
}

lock_take() {
	_lroot=$WPCLI_HOME/lock
	_d=$_lroot/$1
	mkdir -p "$_lroot" 2>/dev/null || die "cannot create $_lroot"
	_try=0
	while :; do
		if mkdir "$_d" 2>/dev/null; then
			if { echo $$ > "$_d/pid.$$" && mv -f "$_d/pid.$$" "$_d/pid"; } 2>/dev/null; then
				LOCKED="$LOCKED $1"
				return 0
			fi
			rm -rf "$_d"
			die "cannot write the lock of $1"
		fi
		# Bounded: a release or a takeover by another run can make us go round, never forever.
		_try=$((_try + 1))
		if [ "$_try" -gt 3 ]; then lock_held "$1" "${_p:-unknown}"; return $?; fi
		# Released since our mkdir: take it.
		[ -d "$_d" ] || continue
		if ! lock_stale "$_d"; then lock_held "$1" "$_p"; return $?; fi
		if ! reap_enter "$_lroot/.reap.$1"; then lock_held "$1" "reaping"; return $?; fi
		if lock_stale "$_d" && mv "$_d" "$_d.stale.$$" 2>/dev/null; then
			rm -rf "$_d.stale.$$"
		fi
		reap_leave
	done
}

# Releases what this process took, and only that: the pid file must still be ours.
lock_release_all() {
	for _s in $LOCKED; do
		_d=$WPCLI_HOME/lock/$_s
		_p=''
		if [ -f "$_d/pid" ]; then read -r _p < "$_d/pid" || _p=''; fi
		[ "$_p" = "$$" ] && rm -rf "$_d"
	done
	LOCKED=''
	reap_leave
	return 0
}

# ---- backups ----------------------------------------------------------------------------

list_backups() {
	[ -d "$BACKUP_ROOT" ] || return 0
	ls -1 "$BACKUP_ROOT" 2>/dev/null | sed -n '/^[0-9]\{8\}-[0-9]\{4\}_/p' | sort -r
}

confirm() {
	[ "$ASSUME_YES" = 1 ] && return 0
	printf '%s [y/N] ' "$1" > /dev/tty
	read ans < /dev/tty || return 1
	case "$ans" in y|Y|yes|oui|o|O) return 0 ;; *) return 1 ;; esac
}

# reads one field of the manifest (never a `source` of a non-code file)
manifest_get() { { grep "^$2=" "$1/manifest" 2>/dev/null || true; } | head -1 | cut -d= -f2- ; }

active_theme_of()   { "$WP" --path="$1" theme  list --status=active --field=name 2>/dev/null | tr '\n' ',' | sed 's/,$//' ; }
active_plugins_of() { "$WP" --path="$1" plugin list --status=active --field=name 2>/dev/null | sort | tr '\n' ',' | sed 's/,$//' ; }

# ---- backup: creates BACKUP_ROOT/<ts>_<site>/; ECHOES the directory on stdout ----
do_backup() {
	is_wp_site "$1" || { warn "unknown WP site: $1"; list_sites >&2; return 1; }
	site=$SITE
	doc=$DOC
	ts=$(date +%Y%m%d-%H%M)
	dir="$BACKUP_ROOT/${ts}_${site}"
	[ ! -e "$dir" ] || die "backup exists already: $dir (retry next minute)"
	mkdir -p "$dir"
	warn "backup $site -> $dir"

	"$WP" --path="$doc" db export "$dir/db.sql" >&2
	[ -s "$dir/db.sql" ] || { rm -rf "$dir"; die "empty db export: $site"; }

	# cache excluded: regenerable and holding stale URLs. uploads INCLUDED (media) -> WP_USER
	# must be able to read it (uploads = www:www 0770 -> WP_USER belongs to the www group).
	[ -x "$GTAR" ] || { rm -rf "$dir"; die "gtar required ($GTAR; pkg_add gtar--)"; }
	"$GTAR" czf "$dir/files.tar.gz" -C "$doc" --exclude=./wp-content/cache .
	gzip -t "$dir/files.tar.gz" 2>/dev/null || { rm -rf "$dir"; die "corrupted file archive: $site"; }

	{
		echo "site=$site"
		echo "url=$("$WP" --path="$doc" option get home 2>/dev/null || echo "https://$site")"
		echo "prefix=$("$WP" --path="$doc" config get table_prefix 2>/dev/null || echo wp_)"
		echo "wp_version=$("$WP" --path="$doc" core version 2>/dev/null || echo '?')"
		echo "active_theme=$(active_theme_of "$doc")"
		echo "active_plugins=$(active_plugins_of "$doc")"
		echo "ts=$ts"
	} > "$dir/manifest"

	log_action "backup $site -> $dir"
	echo "$dir"
}

# ---- prune: keeps the BACKUP_KEEP newest backups of a site, removes the older ones ----
# A backup directory is named <yyyymmdd>-<hhmm>_<site>; the name sorts oldest first. Only a real
# directory of that exact shape is a candidate: a link is skipped, never followed, and rm -rf
# unlinks the links it meets inside a backup without entering them.
prune_site() {   # $1 = site, locked by the caller
	_n=0
	_removed=0
	_old=$IFS
	IFS='
'
	set -f
	for _name in $(list_backups); do
		case $_name in
			[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]_"$1") ;;
			*) continue ;;
		esac
		_n=$((_n + 1))
		[ "$_n" -gt "$BACKUP_KEEP" ] || continue
		_b=$BACKUP_ROOT/$_name
		if [ -L "$_b" ]; then
			warn "skipped, a symbolic link: $_b"
			continue
		fi
		[ -d "$_b" ] || continue
		if rm -rf -- "$_b"; then
			echo "pruned $_b"
			_removed=$((_removed + 1))
		else
			warn "cannot remove $_b"
			note_rc 1
		fi
	done
	set +f
	IFS=$_old
	if [ "$_removed" -gt 0 ]; then
		log_action "prune $1: $_removed backup(s) removed, $BACKUP_KEEP kept"
	fi
	return 0
}

do_prune() {   # $1 = optional site
	if [ -z "$BACKUP_KEEP" ]; then
		echo "BACKUP_KEEP is not set: no backup is pruned"
		return 0
	fi
	[ ! -L "$BACKUP_ROOT" ] || die "$BACKUP_ROOT is a symbolic link"
	[ -d "$BACKUP_ROOT" ] || return 0
	rc=0
	if [ $# -ge 1 ]; then
		host_ok "$1" || usage_err "not a site name: $1"
		_sites=$1
	else
		_sites=$(list_backups | sed -n 's/^[0-9]\{8\}-[0-9]\{4\}_//p' | sort -u)
	fi
	for _s in $_sites; do
		host_ok "$_s" || continue
		if lock_take "$_s"; then
			prune_site "$_s"
			lock_release_all
		else
			note_rc 3
		fi
	done
	return $rc
}

# ---- post-restore check: target actives == original manifest ----
verify_active() {   # $1=bdir  $2=doc
	exp_theme=$(manifest_get "$1" active_theme)
	exp_plugs=$(manifest_get "$1" active_plugins)
	got_theme=$(active_theme_of "$2")
	got_plugs=$(active_plugins_of "$2")
	[ "$got_theme" = "$exp_theme" ] || die "theme check: expected [$exp_theme] got [$got_theme]"
	[ "$got_plugs" = "$exp_plugs" ] || die "plugin check: expected [$exp_plugs] got [$got_plugs]"
	warn "check OK: theme=$got_theme ; active plugins=$(echo "$got_plugs" | tr ',' '\n' | grep -c . || true)"
}

# ---- pre-restore: target state + message + confirmation (dies without DB creds).
# 3 cases on htdocs: WP (wp-load.php) -> REPLACED; empty -> INSTALLED; non-empty non-WP ->
# warning + Y/N. Always requires a PARENT wp-config, the backup not carrying one.
target_precheck() {   # $1=target  $2=backup (for the message)
	_c=$1; _bk=$2
	resolve "$_c" 2>/dev/null || die "unknown target: $_c"
	_doc=$DOC
	_c=$SITE
	[ -d "$_doc" ] || die "target $_c: htdocs missing ($_doc) -> rerun ansible (docroot created by web_fpm)."
	[ -f "${_doc%/*}/wp-config.php" ] || die "target $_c: no parent wp-config -> no DB creds. Configure it first (ansible: core download + config create)."
	if [ -z "$(ls -A "$_doc" 2>/dev/null)" ]; then
		warn "target $_c: htdocs EMPTY -> the restore INSTALLS the content of $_bk"
		confirm "install $_bk onto $_c (empty htdocs)?" || die "cancelled"
	elif [ -f "$_doc/wp-load.php" ]; then
		warn "target $_c: WP present -> the restore REPLACES the site"
		confirm "restore $_bk -> $_c (OVERWRITES the existing WP)?" || die "cancelled"
	else
		warn "target $_c: htdocs NON-empty and NON-WP (no wp-load.php) -> its content will be OVERWRITTEN"
		confirm "overwrite the non-WP content of $_c with $_bk?" || die "cancelled"
	fi
}

# ---- restore: lays <bdir> onto <target>, preserving the DB identity of the target ----
do_restore() {   # $1=bdir (absolute)  $2=target
	bdir=$1; target=$2
	[ -f "$bdir/manifest" ] && [ -f "$bdir/db.sql" ] && [ -f "$bdir/files.tar.gz" ] || die "incomplete backup: $bdir"
	is_wp_site "$target" || { warn "unknown WP target: $target"; list_sites >&2; die "invalid target"; }
	doc=$DOC
	# The target may have been given as a docroot path: the site NAME is what the URL and every
	# message use, never the raw argument.
	tsite=$SITE
	# wp-config MUST be in the parent (outside htdocs): otherwise the rm of htdocs would delete
	# it without restoring it. A non-migrated site is refused rather than lost.
	[ -f "${doc%/*}/wp-config.php" ] || die "guard rail: wp-config expected in the parent ${doc%/*}/ (non-migrated site?) -- restore refused"

	url_orig=$(manifest_get "$bdir" url)
	prefix_orig=$(manifest_get "$bdir" prefix); [ -n "$prefix_orig" ] || prefix_orig=wp_
	# Target URL = IDENTITY of the site (the domain), not the current home in the DB: after
	# import the DB carries the URL of the SOURCE, and an earlier restore may already have
	# polluted the home -> reading the DB would give a wrong value. Sites installed as
	# https://<site>.
	url_target="https://$tsite"
	# Current DB of the target (through its parent wp-config) -> checked at the end of the
	# restore to have NOT changed (a pre-migration backup can inject a wp-config of ANOTHER DB
	# into htdocs and mask the parent).
	db_before=$("$WP" --path="$doc" config get DB_NAME 2>/dev/null || echo '?')

	warn "restore $(basename "$bdir") -> $tsite"
	# wp-config lives in the PARENT (outside htdocs) -> neither removed by the rm of htdocs nor
	# present in the archive (tar of htdocs) -> preserved automatically, nothing to save.

	# target removal (htdocs only; the parent is untouched). The "2>/dev/null" only swallows the
	# "no match" of the dotfile globs -> htdocs is then CHECKED to be really empty, else the rm
	# failed silently (-> cascading "File exists" at extract time) and we stop dead. The DB is
	# handled AFTER the extract, the core being present.
	# uploads/cache are www:www -> WP_USER cannot rm them. ALL of htdocs is switched to WP_USER
	# (doas root) BEFORE the rm; back to www after the extract.
	doas "$OWNER" "$WP_USER" "$doc" >/dev/null
	rm -rf -- "${doc:?}"/* "${doc:?}"/.[!.]* "${doc:?}"/..?* 2>/dev/null || true
	[ -z "$(ls -A "$doc" 2>/dev/null)" ] || die "htdocs not empty after the rm ($doc) -- restore refused ($WP_USER owner swap failed? doas?)."

	# restoration from the backup. wp-config absent from the archive -> the target keeps its DB
	# creds (parent) with no intervention.
	# -p: restores the MODES of the archive ignoring umask (else 077 masks them).
	# owner/group NOT preserved (extract as non-root WP_USER) -> reconciled by ansible.
	[ -x "$GTAR" ] || die "gtar required ($GTAR; pkg_add gtar--)"
	"$GTAR" xzpf "$bdir/files.tar.gz" -C "$doc"
	# A PRE-migration backup carries htdocs/wp-config.php in its archive (creds of the SOURCE)
	# -> the extract recreates it in htdocs and MASKS the parent wp-config (WP reads htdocs
	# first). It is removed: the wp-config lives in the parent alone.
	rm -f "$doc/wp-config.php"

	# cache excluded from the backup -> recreate it (0770 sticky mode). owner/group set below.
	rm -rf "$doc/wp-content/cache"
	mkdir -p "$doc/wp-content/cache"
	chmod 2770 "$doc/wp-content/cache"

	# Final ownership (extract as WP_USER -> everything WP_USER:daemon):
	#  - code: group www through chgrp (WP_USER owns the extracted files and belongs to www ->
	#    allowed) -> WP_USER:www.
	#  - uploads + cache: owner www (doas root) -> www:www.
	# Modes already right through gtar -p (archive) and the 2770 of the cache.
	chgrp -R "$WWW_USER" "$doc"
	doas "$OWNER" "$WWW_USER" "$doc" >/dev/null

	# DB: reset THEN import, AFTER the extract -> the core is present in htdocs, so wp-cli works
	# even when htdocs started empty (the "install onto empty" case).
	"$WP" --path="$doc" db reset --yes >&2
	"$WP" --path="$doc" db import "$bdir/db.sql" >&2
	# tables imported at the original prefix -> carried over into the target wp-config
	"$WP" --path="$doc" config set table_prefix "$prefix_orig" --type=variable >&2

	# URL rewrite ANCHORED on "//" (covers https://, http:// and the protocol-relative //dom).
	# NEVER the bare domain, for two reasons:
	#   (1) it clobbers emails: x@hors-norme.fr -> x@beta.hors-norme.fr;
	#   (2) dom_c CONTAINS dom_o (beta.hors-norme.fr includes hors-norme.fr) -> it double
	#       prefixes URLs already rewritten (https://beta.hors-norme.fr -> ...beta.beta...).
	# "//dom" avoids both: an email has no "//"; after the rewrite the "//" precedes the NEW
	# domain, so no residual match remains. One pass is enough.
	if [ -n "$url_orig" ] && [ "$url_orig" != "$url_target" ]; then
		dom_o=${url_orig#http://}; dom_o=${dom_o#https://}; dom_o=${dom_o%%/*}
		dom_c=${url_target#http://}; dom_c=${dom_c#https://}; dom_c=${dom_c%%/*}
		if [ "$dom_o" != "$dom_c" ]; then
			"$WP" --path="$doc" search-replace "//$dom_o" "//$dom_c" \
				--all-tables-with-prefix --skip-columns=guid --report-changed-only >&2 || true
		fi
	fi

	# Guarantee: forces home/siteurl to the target URL (beyond the search-replace, which can
	# miss a differently stored form; idempotent when already right, and a rollback path).
	"$WP" --path="$doc" option update home    "$url_target" >&2
	"$WP" --path="$doc" option update siteurl "$url_target" >&2

	# No `wp cache flush`: in CLI the APCu of FPM is another SAPI, unreachable. The real flush is
	# a php-fpm restart (final reminder, root).
	# Functional check: the target STILL points at its own DB (no shadow injected by the
	# archive) AND connects / is installed. --url supplies the host, avoiding the HTTP_HOST
	# warning in CLI.
	db_after=$("$WP" --path="$doc" config get DB_NAME 2>/dev/null || echo '?')
	[ "$db_before" = "$db_after" ] || die "post-restore: DB_NAME changed ($db_before -> $db_after) -- shadow wp-config in htdocs? -> stop"
	"$WP" --path="$doc" --url="$url_target" core is-installed 2>/dev/null \
		|| die "post-restore: DB unreachable / WP not installed on $tsite -> check wp-config + DB"

	verify_active "$bdir" "$doc"
	log_action "restore $(basename "$bdir") -> $tsite"
	warn "restore OK: $tsite"
	warn "perms OK (code $WP_USER:$WWW_USER, uploads/cache $WWW_USER:$WWW_USER, modes through -p)."
	warn ""
	warn "############################################################"
	warn "#  ACTION REQUIRED, AS ROOT (not $WP_USER):"
	warn "#      rcctl restart php<version>_fpm"
	warn "#"
	warn "#  The restore mutated the DB through wp-cli (CLI SAPI). The"
	warn "#  APCu of php-fpm is ANOTHER SAPI, not emptied by the CLI ->"
	warn "#  it serves STALE roles / data -> admin LOCKOUT / stale"
	warn "#  content until FPM is restarted. wp cache flush is NOT"
	warn "#  enough (other SAPI)."
	warn "############################################################"
	warn "Then rerun ansible (reconciles plugins/mu-plugins from host_vars)."
}

do_info() {   # $1 = site OR backup name
	name=$1
	case $name in
		''|.*|*/*) ;;
		*)
			if [ -f "$BACKUP_ROOT/$name/manifest" ]; then
				cat "$BACKUP_ROOT/$name/manifest"
				return 0
			fi
			;;
	esac
	if is_wp_site "$name"; then
		doc=$DOC
		echo "site=$SITE"
		echo "docroot=$doc"
		echo "url=$("$WP" --path="$doc" option get home 2>/dev/null || echo '?')"
		echo "prefix=$("$WP" --path="$doc" config get table_prefix 2>/dev/null || echo '?')"
		echo "wp_version=$("$WP" --path="$doc" core version 2>/dev/null || echo '?')"
		echo "active_theme=$(active_theme_of "$doc" | tr ',' ' ')"
		echo "active_plugins=$(active_plugins_of "$doc" | tr ',' ' ')"
		echo "installed=$("$WP" --path="$doc" core is-installed >/dev/null 2>&1 && echo yes || echo no)"
		return 0
	fi
	die "unknown (neither backup nor WP site): $name"
}

do_migrate() {   # $1=source  $2=target
	src=$1; dst=$2
	[ "$src" = "$dst" ] && die "source == target"
	is_wp_site "$src" || { warn "unknown source: $src"; list_sites >&2; die "invalid source"; }
	ssite=$SITE
	is_wp_site "$dst" || { warn "unknown target: $dst";  list_sites >&2; die "invalid target"; }
	dsite=$SITE
	confirm "migrate $ssite -> $dsite (OVERWRITES $dsite)?" || die "cancelled"
	ASSUME_YES=1

	warn "== source backup =="
	dir_s=$(do_backup "$src")
	warn "== target backup (rollback) =="
	dir_c=$(do_backup "$dst")
	warn "== restore source -> target =="
	do_restore "$dir_s" "$dst"

	echo ""
	echo "migration OK: $ssite -> $dsite"
	echo "rollback   : $ME restore $(basename "$dir_c") $dsite"
	echo "IMPORTANT (root, not $WP_USER): rcctl restart php<version>_fpm ; rerun ansible (reconciles plugins/mu-plugins, empties the FPM APCu)"
	log_action "migrate $ssite -> $dsite"
}

# ---- sync: the shipped mu-plugins and drop-ins, copied into each site ----
# /usr/local and /var/www are different file systems and a chrooted pool follows no link out of
# its tree: the files are copied into wp-content of every site. The manifest of the package,
# SHARE/manifest, lists what is shipped, one "<class> <path>" per line: class "default" for what
# every site gets without -f, "optional" for the rest; path is relative to wp-content and to
# SHARE/wp-content. Nothing is ever removed: a retired file is removed by whoever retires it.
manifest_read() {   # fills MF_ALL (every path) and MF_DEFAULT
	MF_ALL=''; MF_DEFAULT=''
	[ -f "$SHARE/manifest" ] || die "manifest missing: $SHARE/manifest"
	while read -r _cls _rel; do
		case $_cls in ''|'#'*) continue ;; esac
		case $_cls in default|optional) ;; *) die "manifest: unknown class $_cls" ;; esac
		case $_rel in
			''|/*|*..*|*[!A-Za-z0-9._/-]*) die "manifest: unsafe path: $_rel" ;;
		esac
		[ -f "$SHARE/wp-content/$_rel" ] || die "manifest names a file that is not shipped: $_rel"
		MF_ALL="$MF_ALL $_rel"
		[ "$_cls" = default ] && MF_DEFAULT="$MF_DEFAULT $_rel"
	done < "$SHARE/manifest"
	return 0
}

same_file() {   # $1=src $2=dst: same bytes, same mode, a regular file and no link
	[ -f "$2" ] && [ ! -L "$2" ] || return 1
	cmp -s "$1" "$2" || return 1
	[ -n "$(find "$2" -prune -perm "$CODE_FILE_MODE")" ]
}

# put_file src dst: written beside the destination and renamed over it, so a reader of the site
# never meets half a file, and a link in the way is replaced, not followed.
put_file() {
	_t=$2.$ME.$$
	if cp "$1" "$_t" && chmod "$CODE_FILE_MODE" "$_t" && mv -f "$_t" "$2"; then
		return 0
	fi
	rm -f "$_t"
	return 1
}

# sync_one: SITE and DOC are set, SELECT is the list. Counts in SYNC_DIFF and SYNC_WROTE.
sync_one() {
	if [ ! -d "$DOC/wp-content" ]; then
		warn "$SITE: no $DOC/wp-content"
		return 1
	fi
	_w=0
	_rc=0
	for _rel in $SELECT; do
		_src=$SHARE/wp-content/$_rel
		_dst=$DOC/wp-content/$_rel
		same_file "$_src" "$_dst" && continue
		SYNC_DIFF=$((SYNC_DIFF + 1))
		if [ "$DRY" = 1 ]; then
			echo "differs: $_dst"
			continue
		fi
		_dir=${_dst%/*}
		if [ ! -d "$_dir" ]; then
			if ! { mkdir "$_dir" && chmod "$CODE_DIR_MODE" "$_dir"; }; then
				warn "$SITE: cannot create $_dir"
				_rc=1
				continue
			fi
		fi
		if put_file "$_src" "$_dst"; then
			echo "wrote: $_dst"
			_w=$((_w + 1))
		else
			warn "$SITE: cannot write $_dst"
			_rc=1
		fi
	done
	SYNC_WROTE=$((SYNC_WROTE + _w))
	if [ "$_w" -gt 0 ]; then
		log_action "sync $SITE: $_w file(s) written"
	fi
	return $_rc
}

cmd_sync() {
	DRY=0
	names=''
	while [ $# -gt 0 ]; do
		case $1 in
			-n) DRY=1; shift ;;
			-f)
				[ $# -ge 2 ] || usage_err "sync: -f needs a file name"
				case $2 in ''|*[!A-Za-z0-9._-]*|.*) usage_err "sync: bad file name: $2" ;; esac
				names="$names $2"
				shift 2
				;;
			--) shift; break ;;
			-*) usage_err "sync: unknown option: $1" ;;
			*) break ;;
		esac
	done
	need_wp_user
	manifest_read
	SELECT=''
	if [ -n "$names" ]; then
		for _n in $names; do
			_hit=''
			for _rel in $MF_ALL; do
				[ "${_rel##*/}" = "$_n" ] && _hit=$_rel
			done
			[ -n "$_hit" ] || usage_err "sync: $_n is not in $SHARE/manifest"
			SELECT="$SELECT $_hit"
		done
	else
		SELECT=$MF_DEFAULT
	fi
	if [ $# -eq 0 ]; then
		# shellcheck disable=SC2046
		set -- $(list_sites)
		[ $# -gt 0 ] || { warn "no WP site found"; return 0; }
	fi
	SYNC_DIFF=0
	SYNC_WROTE=0
	umask 022
	rc=0
	for a in "$@"; do
		resolve "$a" || { note_rc $?; continue; }
		if [ "$DRY" = 1 ]; then
			sync_one || note_rc 1
			continue
		fi
		if lock_take "$SITE"; then
			sync_one || note_rc 1
			lock_release_all
		else
			note_rc 3
		fi
	done
	# -n: any failure above is the status, first one first; only when every site was readable does
	# the count of differing files give 10 + count (110 at most).
	if [ "$DRY" = 1 ]; then
		[ "$rc" = 0 ] || return $rc
		[ "$SYNC_DIFF" -eq 0 ] && return 0
		[ "$SYNC_DIFF" -gt 100 ] && return 110
		return $((10 + SYNC_DIFF))
	fi
	[ "$SYNC_WROTE" -eq 0 ] || echo changed
	return $rc
}

# ---- update: themes, plugins, translations; or the core ----
# --skip-plugins --skip-themes on every command: a plugin or theme fatal at bootstrap (e.g. the
# APCu object-cache in CLI) would kill wp-cli before the update. We want to update despite a
# broken site -- that is precisely when an update repairs.
# Sequential, no short-circuiting &&: these are security fixes, and a failing step must not block
# the following ones. rc=1 as soon as a command fails.
# SILENT when nothing was updated: run from /etc/hourly, where the mail is triggered by the
# OUTPUT, not by the exit code -- wp-cli says "Success: ... already updated" every single run,
# and five of those per site per hour is noise nobody reads. A step whose output holds nothing
# but those lines prints nothing; anything else goes out whole, context included.
NOOP='^Success: (Theme|Plugin) already updated\.$|^Success: Translations are up to date\.$'

update_step() {
	_out=$("$@" 2>&1) || _step_rc=1
	[ -n "$_out" ] || return 0
	if printf '%s\n' "$_out" | grep -qvE "$NOOP"; then
		printf '%s\n' "$_out"
		_step_out=1
	fi
	return 0
}

update_themes_plugins() {   # DOC set
	_step_rc=0
	_step_out=0
	update_step "$WP" --path="$DOC" --skip-plugins --skip-themes theme update --all
	update_step "$WP" --path="$DOC" --skip-plugins --skip-themes plugin update --all
	update_step "$WP" --path="$DOC" --skip-plugins --skip-themes language core update
	update_step "$WP" --path="$DOC" --skip-plugins --skip-themes language plugin update --all
	update_step "$WP" --path="$DOC" --skip-plugins --skip-themes language theme update --all
	# One syslog line when a step had something to say, not one per site and hour.
	[ "$_step_out" = 0 ] || log_action "update themes+plugins $SITE"
	return $_step_rc
}

# Core update. wp-cli 2.12.0 extracts the core tarball with PharData FIRST. PharData succeeds
# while silently truncating GNU @LongLink paths longer than the 100-char ustar name field, so
# WP 7.0's deeply nested wp-includes/php-ai-client/* files come out truncated and
# `wp core verify-checksums` fails. The system tar is only a fallback that PharData success never
# triggers, so no PATH/shim trick helps. Core auto-update is disabled for this reason; this runs
# `wp core update`, then re-extracts the affected long-path dir with gtar (GNU tar handles
# @LongLink), then verifies. Doubles as a one-shot repair: run it on an already-current site to
# fix a previously-truncated php-ai-client.
update_core() {   # DOC set; one site, in a subshell, every step checked by hand
	# No reliance on set -e here: it is ignored inside a function called from an `if`, and this
	# one is, so each step names its own failure.
	repair_dir=wp-includes/php-ai-client     # the WP 7.0 dir with paths > 100 chars
	(
		tmp=$(mktemp -d "${TMPDIR:-/tmp}/wpchroot.XXXXXXXXXX") || exit 1
		trap 'rm -rf "$tmp"' EXIT

		"$WP" --path="$DOC" core update || true

		v=$("$WP" --path="$DOC" core version) || exit 1
		t="$WPCLI_HOME/.wp-cli/cache/core/wordpress-${v}-en_US.tar.gz"
		if [ ! -f "$t" ]; then
			echo "-- cached tarball absent, downloading wordpress-${v}.tar.gz"
			ftp -o "$tmp/wp.tgz" "https://wordpress.org/wordpress-${v}.tar.gz" || exit 1
			t="$tmp/wp.tgz"
		fi

		# Extracted aside and checked BEFORE the live directory is touched.
		echo "-- re-extracting ${repair_dir} with gtar"
		"$GTAR" -xz -C "$tmp" -f "$t" "wordpress/${repair_dir}" || exit 1
		[ -d "$tmp/wordpress/${repair_dir}" ] || { echo "${repair_dir} is not in the tarball" >&2; exit 1; }
		rm -rf "${DOC:?}/${repair_dir}" || exit 1
		cp -Rp "$tmp/wordpress/${repair_dir}" "$DOC/wp-includes/" || exit 1
		# The extracted files belong to the running account, which is in the WWW_USER group.
		chgrp -R "$WWW_USER" "$DOC/${repair_dir}" || exit 1

		"$WP" --path="$DOC" core verify-checksums || exit 1
	)
}

cmd_update() {
	core=0
	case ${1:-} in
		-c) core=1; shift ;;
		-*) usage_err "update: unknown option: $1" ;;
	esac
	[ $# -ge 1 ] || usage_err "update: a site or a docroot is required"
	need_wp_user
	[ "$core" = 0 ] || [ -x "$GTAR" ] || die "gtar required ($GTAR; pkg_add gtar--)"
	umask 022
	export HOME=$WPCLI_HOME   # wp-cli reads its config and cache under the home of WP_USER
	rc=0
	for a in "$@"; do
		resolve "$a" || { note_rc $?; continue; }
		if [ ! -f "$DOC/wp-load.php" ]; then
			warn "not a WP docroot: $DOC"
			note_rc 1
			continue
		fi
		if ! lock_take "$SITE"; then
			note_rc 3
			continue
		fi
		if [ "$core" = 1 ]; then
			echo "==== $DOC ===="
			if update_core; then
				log_action "update core $SITE"
			else
				echo "FAIL: $DOC (see output above)" >&2
				note_rc 1
			fi
		else
			update_themes_plugins || note_rc 1
		fi
		lock_release_all
	done
	return $rc
}

# ---- main ----

trap 'lock_release_all' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

while [ $# -gt 0 ]; do
	case "$1" in
		-y|--yes) ASSUME_YES=1; shift ;;
		--) shift; break ;;
		-*) usage_err "unknown option: $1" ;;
		*)  break ;;
	esac
done

verb=${1:-}
[ $# -gt 0 ] && shift || true

case "$verb" in
	help|-h|--help) usage; exit 0 ;;
	'') usage; exit 2 ;;
	perms)
		# The root helper reads WWW alone from the configuration.
		[ "$(id -u)" = 0 ] || die "perms must run as root"
		exec "$PERMS" "$@"
		;;
esac

conf_load

case "$verb" in
	info)
		if [ $# -eq 0 ]; then list_sites; else [ $# -eq 1 ] || usage_err "info: one argument"; do_info "$1"; fi
		;;
	backup)
		if [ "${1:-}" = "--prune" ]; then
			shift
			[ $# -le 1 ] || usage_err "backup --prune: at most one site"
			need_wp_user
			umask 077
			if do_prune "$@"; then exit 0; else exit $?; fi
		fi
		if [ $# -eq 0 ]; then
			list_sites
		else
			[ $# -eq 1 ] || usage_err "backup: one site"
			need_wp_user
			umask 077                       # backups = data + wp-config (secrets) -> private
			is_wp_site "$1" || { warn "unknown WP site: $1"; list_sites >&2; exit 1; }
			lock_take "$SITE" || exit 3
			do_backup "$1"
		fi
		;;
	restore)
		if [ $# -eq 0 ]; then
			list_backups
		elif [ $# -eq 1 ]; then
			die "target required -- usage: $ME restore <backup> <target>  (backups: $ME restore)"
		else
			[ $# -eq 2 ] || usage_err "restore: <backup> <target>"
			need_wp_user
			umask 077
			# validate BOTH arguments BEFORE asking for confirmation
			case $1 in ''|.*|*/*) usage_err "bad backup name: $1" ;; esac
			bdir="$BACKUP_ROOT/$1"
			[ -d "$bdir" ] && [ -f "$bdir/manifest" ] && [ -f "$bdir/db.sql" ] && [ -f "$bdir/files.tar.gz" ] \
				|| { warn "unknown/incomplete backup: $1"; list_backups >&2; die "invalid backup"; }
			is_wp_site "$2" || { warn "unknown WP target: $2"; list_sites >&2; die "invalid target"; }
			lock_take "$SITE" || exit 3
			target_precheck "$2" "$1"      # target state (WP/empty/non-WP) + message + confirm
			do_restore "$bdir" "$2"
		fi
		;;
	migrate)
		[ $# -eq 2 ] || usage_err "usage: $ME migrate <source> <target>"
		need_wp_user
		umask 077
		is_wp_site "$1" || { warn "unknown source: $1"; list_sites >&2; die "invalid source"; }
		_s1=$SITE
		is_wp_site "$2" || { warn "unknown target: $2";  list_sites >&2; die "invalid target"; }
		_s2=$SITE
		[ "$_s1" != "$_s2" ] || die "source == target"
		# Both sites are taken, in name order; a second refusal releases the first (EXIT trap).
		_a=$(printf '%s\n%s\n' "$_s1" "$_s2" | sort | head -1)
		if [ "$_a" = "$_s1" ]; then _b=$_s2; else _b=$_s1; fi
		lock_take "$_a" || exit 3
		lock_take "$_b" || exit 3
		do_migrate "$1" "$2"
		;;
	sync)   cmd_sync "$@" ;;
	update) cmd_update "$@" ;;
	*) usage_err "unknown subcommand: $verb" ;;
esac
