#!/usr/bin/env bash
#
# mp-relocate-fix.sh: repoint a MultiPortal install at the directory the code
# actually lives in, after the install has been moved or renamed.
#
# Rewrites the stale /var/www/<old-dir> path in every place the platform records
# it:
#
#   1. /etc/systemd/system/multiportal.service         (legacy single queue worker)
#   2. /etc/systemd/system/multiportal-queue@.service  (queue worker template)
#   3. the cron/index hook  (root crontab, web-user crontab, /etc/cron.d/*)
#   4. the pm2 process for the websocket console server
#   5. /etc/caddy/Caddyfile + /etc/caddy/conf.d/*.caddy  (opt-in, --fix-caddy)
#   6. ownership of the runtime/log/backup directories
#
# It only rewrites paths. It never replaces a unit's ExecStart command, and it
# never edits HOST_NAME unless you ask for it with --set-host-name (HOST_NAME is
# the browser-facing domain and the pm2 process name, not a filesystem path).
#
# Multi-install hosts: the crontab and the Caddy config legitimately carry one
# entry per install. Only the *stale* path is rewritten there: by default the
# /var/www/<dir> that no longer exists on disk (i.e. the one you moved away
# from), or exactly the paths you name with --from. Entries belonging to other
# live installs are reported and left alone.
#
# Usage:
#   sudo ./mp-relocate-fix.sh [TARGET_DIR] [options]
#
# Self-contained: it reads nothing relative to its own location, so it can be
# run from anywhere on the host. The install directory comes from TARGET_DIR or
# is detected under /var/www.
#
# Options:
#   --from PATH            The old install path to replace (repeatable). Default:
#                          any /var/www/<dir> referenced that no longer exists.
#   --yes                  Non-interactive; assume "yes" at the confirmation.
#   --dry-run              Show every change that would be made, change nothing.
#   --set-host-name[=NAME] Also set HOST_NAME in .env (default NAME: basename of
#                          the install dir) and re-register the pm2 process
#                          under the new name. Off by default.
#   --fix-caddy            Also rewrite stale install paths in the Caddy config,
#                          validate it, and reload Caddy. Off by default.
#   --disable-legacy-app-unit
#                          Stop and disable multiportal.service instead of
#                          restarting it. Use when multiportal-queue@ is also
#                          present, since the two otherwise drain the same queue.
#   --workers N            Worker count to start when no existing queue
#                          instances can be detected (default: 2).
#   --php /path/to/php     PHP binary to record in units/cron (default: the php
#                          on PATH).
#   -V, --version          Print this script's version and exit.
#   -h, --help             This help.
#
set -euo pipefail

# Version of this script, independent of the portal's version.txt. Bump it on
# any behaviour change: this script is handed out ad hoc, so the number in a
# pasted transcript is the only way to know which copy was run.
SCRIPT_VERSION="1.1.0"

SERVICE_NAME="multiportal-queue"
APP_UNIT="/etc/systemd/system/multiportal.service"
QUEUE_UNIT="/etc/systemd/system/${SERVICE_NAME}@.service"
CADDYFILE="/etc/caddy/Caddyfile"
CADDY_CONF_D="/etc/caddy/conf.d"

ASSUME_YES=0
DRY_RUN=0
SET_HOST_NAME=0
HOST_NAME_NEW=""
FIX_CADDY=0
DEFAULT_WORKERS=2
PHP_BIN=""
TARGET=""
FROM_PATHS=()
DISABLE_LEGACY=0
APP_UNIT_WAS_ACTIVE=0
APP_UNIT_IS_LISTENER=0

FAILURES=()

# ---------------------------------------------------------------------------
# output helpers
# ---------------------------------------------------------------------------
info() { printf '   %s\n' "$*"; }
step() { printf '\n== %s\n' "$*"; }
ok()   { printf '   [ok] %s\n' "$*"; }
warn() { printf '   [warn] %s\n' "$*" >&2; }
fail() { printf '   [FAIL] %s\n' "$*" >&2; FAILURES+=("$*"); }
die()  { printf 'error: %s\n' "$*" >&2; exit 1; }

usage() { sed -n '2,/^set -euo/p' "$0" | sed 's/^# \{0,1\}//;$d'; exit 0; }

# Escape a string for safe use on the right-hand side of `sed s|...|X|`.
sed_escape_repl() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; }

# Back up $1 into $BACKUP_DIR, flattening its path into the filename.
backup_file() {
  [[ -f "$1" ]] || return 0
  [[ $DRY_RUN -eq 1 ]] && return 0
  cp -a "$1" "$BACKUP_DIR/$(printf '%s' "${1#/}" | tr '/' '_')"
}

# Print every distinct /var/www/<dir> path referenced by $1 other than $TARGET.
foreign_paths_in() {
  grep -ohE '/var/www/[^/[:space:]"'"'"']+' "$1" 2>/dev/null | sort -u | grep -vxF "$TARGET" || true
}

# Print the subset of those paths that are safe to rewrite: the ones named by
# --from, or (with no --from) the ones whose directory no longer exists.
# A path that still exists is another live install and must not be touched.
stale_paths_in() {
  local p f
  while IFS= read -r p; do
    [[ -n "$p" ]] || continue
    if [[ ${#FROM_PATHS[@]} -gt 0 ]]; then
      for f in "${FROM_PATHS[@]}"; do
        if [[ "$p" == "$f" ]]; then
          printf '%s\n' "$p"
          break
        fi
      done
    elif [[ ! -d "$p" ]]; then
      printf '%s\n' "$p"
    fi
  done < <(foreign_paths_in "$1")
  return 0   # a no-match loop must not look like a failure under `set -e`
}

# Build the sed expression that repoints $1 at $TARGET, or print nothing when
# there is nothing to rewrite.
#   $2 = mode: "all"   every /var/www/<dir> (singleton systemd units only)
#              "stale" only the paths stale_paths_in() reports
#   $3 = optional extended regex; restricts the rewrite to matching lines
sed_expr_for() {
  local file="$1" mode="$2" line_filter="${3:-}" repl expr old
  repl="$(sed_escape_repl "$TARGET")"

  if [[ "$mode" == "all" ]]; then
    expr="s|/var/www/[^/[:space:]\"']*|${repl}|g"
  else
    expr=""
    while IFS= read -r old; do
      [[ -n "$old" ]] || continue
      # \b so /var/www/old does not also match inside /var/www/oldie
      expr+="s|$(sed_escape_repl "$old")\\b|${repl}|g;"
    done < <(stale_paths_in "$file")
    [[ -n "$expr" ]] || return 0
  fi

  [[ -n "$line_filter" ]] && expr="/${line_filter}/ { ${expr} }"
  printf '%s' "$expr"
}

# Rewrite install paths in $1 to $TARGET; args as sed_expr_for().
# Returns 0 changed, 1 no such file, 2 nothing to do.
rewrite_paths() {
  local file="$1" mode="$2" line_filter="${3:-}" expr tmp
  [[ -f "$file" ]] || return 1

  expr="$(sed_expr_for "$file" "$mode" "$line_filter")"
  [[ -n "$expr" ]] || return 2

  tmp="$(mktemp)"
  sed -E "$expr" "$file" > "$tmp"
  if cmp -s "$file" "$tmp"; then
    rm -f "$tmp"
    return 2   # already correct
  fi
  if [[ $DRY_RUN -eq 1 ]]; then
    diff -u "$file" "$tmp" | sed 's/^/     /' || true
    rm -f "$tmp"
    return 0
  fi
  backup_file "$file"
  cat "$tmp" > "$file"   # preserve the original inode, owner and mode
  rm -f "$tmp"
  return 0
}

# ---------------------------------------------------------------------------
# argument parsing
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
  case "$1" in
    -h|--help)          usage ;;
    -V|--version)       printf 'mp-relocate-fix.sh %s\n' "$SCRIPT_VERSION"; exit 0 ;;
    --yes|-y)           ASSUME_YES=1 ;;
    --dry-run)          DRY_RUN=1 ;;
    --fix-caddy)        FIX_CADDY=1 ;;
    --disable-legacy-app-unit) DISABLE_LEGACY=1 ;;
    --set-host-name)    SET_HOST_NAME=1 ;;
    --set-host-name=*)  SET_HOST_NAME=1; HOST_NAME_NEW="${1#*=}" ;;
    --from)             FROM_PATHS+=("$(realpath -m "${2:-}")"); shift ;;
    --from=*)           FROM_PATHS+=("$(realpath -m "${1#*=}")") ;;
    --workers)          DEFAULT_WORKERS="${2:-}"; shift ;;
    --workers=*)        DEFAULT_WORKERS="${1#*=}" ;;
    --php)              PHP_BIN="${2:-}"; shift ;;
    --php=*)            PHP_BIN="${1#*=}" ;;
    -*)                 die "unknown option: $1 (try --help)" ;;
    *)                  [[ -z "$TARGET" ]] || die "unexpected argument: $1"; TARGET="$1" ;;
  esac
  shift
done

[[ "$DEFAULT_WORKERS" =~ ^[0-9]+$ ]] || die "--workers must be a number"
[[ $EUID -eq 0 ]] || die "please run with sudo/root."
command -v systemctl >/dev/null 2>&1 || die "systemctl not found; this script targets systemd hosts."

# ---------------------------------------------------------------------------
# 1) resolve the install directory
# ---------------------------------------------------------------------------
step "Install directory"

is_install_dir() { [[ -f "$1/yii" && -d "$1/backend/web" ]]; }

if [[ -n "$TARGET" ]]; then
  TARGET="$(realpath -m "$TARGET")"
  is_install_dir "$TARGET" || die "$TARGET is not a MultiPortal install (need 'yii' + 'backend/web')."
else
  # Enumerate *every* candidate. Picking the first hit silently is how a host
  # with an old install beside a new one gets repointed at the wrong one.
  CANDIDATES=()
  while IFS= read -r d; do
    is_install_dir "$d" && CANDIDATES+=("$d")
  done < <(find /var/www -maxdepth 2 -type f -name yii -printf '%h\n' 2>/dev/null | sort -u)

  case "${#CANDIDATES[@]}" in
    0) die "no MultiPortal install found under /var/www; pass the directory explicitly." ;;
    1) TARGET="${CANDIDATES[0]}" ;;
    *)
      printf 'error: multiple installs found under /var/www; pass the one you want explicitly:\n' >&2
      printf '  %s\n' "${CANDIDATES[@]}" >&2
      exit 1
      ;;
  esac
fi

DIR_NAME="$(basename "$TARGET")"
PARENT_DIR="$(dirname "$TARGET")"
# Matches PM2Helper::checkPm2Folder(): @app/../../.pm2, i.e. one level above
# the install dir.
PM2_HOME="$PARENT_DIR/.pm2"
info "Install dir: $TARGET"

# Cross-check against the site root Caddy is actually serving.
CADDY_ROOT=""
if [[ -f "$CADDYFILE" ]]; then
  CADDY_ROOT="$(grep -hoE '^[[:space:]]*root[[:space:]]+\*[[:space:]]+/var/www/[^[:space:]]+' "$CADDYFILE" 2>/dev/null \
                | awk '{print $3}' | sed 's#/backend/web/*$##' | sort -u | head -n1 || true)"
  if [[ -n "$CADDY_ROOT" && "$CADDY_ROOT" != "$TARGET" ]]; then
    warn "Caddy serves $CADDY_ROOT but this run targets $TARGET."
    warn "If $TARGET is correct, re-run with --fix-caddy (or fix $CADDYFILE by hand)."
  fi
fi

# ---------------------------------------------------------------------------
# 2) derive the system user + php binary (mirrors common\helpers\SystemHelper)
# ---------------------------------------------------------------------------
OS_ID="$(grep -E '^ID=' /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"'\''' || true)"
case "$OS_ID" in
  debian|ubuntu)      SYS_USER="www-data" ;;
  almalinux|rocky)    SYS_USER="caddy" ;;
  *)                  SYS_USER="www-data" ;;
esac
id -u "$SYS_USER" >/dev/null 2>&1 || die "expected system user '$SYS_USER' (os id: ${OS_ID:-unknown}) does not exist."

if [[ -z "$PHP_BIN" ]]; then
  PHP_BIN="$(command -v php || true)"
fi
[[ -x "$PHP_BIN" ]] || die "no usable PHP binary (looked for 'php' on PATH); pass --php /usr/bin/php8.2"

info "System user: $SYS_USER    PHP: $PHP_BIN"

# ---------------------------------------------------------------------------
# 3) read HOST_NAME (report only, unless --set-host-name)
# ---------------------------------------------------------------------------
step "HOST_NAME"

ENV_FILE="$TARGET/.env"
HOST_NAME_CUR=""

if [[ -f "$ENV_FILE" ]]; then
  HOST_NAME_CUR="$(grep -E '^[[:space:]]*(export[[:space:]]+)?HOST_NAME=' "$ENV_FILE" 2>/dev/null \
                   | tail -n1 | cut -d= -f2- | sed -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/" || true)"
  if [[ -n "$HOST_NAME_CUR" ]]; then
    if [[ "$HOST_NAME_CUR" == "$DIR_NAME" ]]; then
      ok "HOST_NAME=$HOST_NAME_CUR matches the directory name."
    else
      warn "HOST_NAME=$HOST_NAME_CUR but the directory is named $DIR_NAME."
      warn "That is only a problem if $DIR_NAME is the real portal FQDN. HOST_NAME is the"
      warn "browser-facing domain (API base URL, WebSocket origin allow-list, pm2 process"
      warn "name), not a path. Left alone unless you pass --set-host-name."
    fi
  else
    warn "No HOST_NAME set in $ENV_FILE; the portal cannot work without it."
  fi
else
  die "$ENV_FILE not found. A relocated install must bring its .env with it; this script will not invent one."
fi

if [[ $SET_HOST_NAME -eq 1 ]]; then
  [[ -n "$HOST_NAME_NEW" ]] || HOST_NAME_NEW="$DIR_NAME"
  info "Will set HOST_NAME to: $HOST_NAME_NEW"
fi

# ---------------------------------------------------------------------------
# 4) plan + confirmation
# ---------------------------------------------------------------------------
step "Plan"

# multiportal.service is the legacy single queue listener: its ExecStart is
# `yii queue/listen`, and on most installs it runs as root. Where the
# multiportal-queue@ template also exists, the two drain the same queue table,
# and the root-run one root-owns whatever its jobs write under backend/runtime.
if [[ -f "$APP_UNIT" ]]; then
  systemctl is-active --quiet multiportal.service && APP_UNIT_WAS_ACTIVE=1 || true
  grep -qE '^[[:space:]]*ExecStart=.*queue/listen' "$APP_UNIT" && APP_UNIT_IS_LISTENER=1 || true

  if [[ $APP_UNIT_IS_LISTENER -eq 1 && -f "$QUEUE_UNIT" ]]; then
    warn "multiportal.service runs 'yii queue/listen' and ${SERVICE_NAME}@.service also exists."
    warn "Two listeners on one queue means jobs are consumed twice. multiportal.service is the"
    warn "legacy single worker; ${SERVICE_NAME}@N is the current one."
    if [[ $DISABLE_LEGACY -eq 1 ]]; then
      warn "--disable-legacy-app-unit given: it will be stopped and disabled."
    elif [[ $APP_UNIT_WAS_ACTIVE -eq 1 ]]; then
      warn "It is running now, so it will be repointed and restarted. To retire it instead,"
      warn "re-run with --disable-legacy-app-unit."
    else
      warn "It is not running, so it will be repointed but left stopped."
    fi
  fi
fi

# Both units are singletons (there is one of each per host), so pointing them
# here takes them away from whoever holds them now. Say so before asking for
# confirmation, not after.
for unit in "$APP_UNIT" "$QUEUE_UNIT"; do
  [[ -f "$unit" ]] || continue
  while IFS= read -r p; do
    [[ -n "$p" ]] || continue
    if [[ -d "$p" ]]; then
      warn "$(basename "$unit") currently points at $p, which is still a live install."
      warn "Continuing takes that unit over. Ctrl-C now if this host runs more than one portal."
    fi
  done < <(foreign_paths_in "$unit")
done

cat <<PLAN
   Repoint every recorded install path at: $TARGET

     - $APP_UNIT           (legacy queue worker; paths only, command untouched)
     - $QUEUE_UNIT   (rewrite paths only)
     - cron/index hook in root + $SYS_USER crontabs and /etc/cron.d
     - pm2 websocket process (re-register from the new path)
     - ownership of runtime/, logs/, _backup/ -> $SYS_USER

   Stale path(s) to replace in cron/Caddy: $( [[ ${#FROM_PATHS[@]} -gt 0 ]] && printf '%s ' "${FROM_PATHS[@]}" || echo "auto (any /var/www/<dir> that no longer exists)" )
$( [[ $SET_HOST_NAME -eq 1 ]] && echo "     - HOST_NAME in .env -> $HOST_NAME_NEW (+ pm2 rename)" )
$( [[ $FIX_CADDY -eq 1 ]]     && echo "     - Caddy config paths (validated before reload)" )

   Backups of every file touched go to: /var/backups/mp-relocate-<timestamp>/
PLAN

if [[ $DRY_RUN -eq 1 ]]; then
  info "DRY RUN: nothing will be changed."
elif [[ $ASSUME_YES -eq 0 ]]; then
  if [[ ! -t 0 ]]; then
    die "not running on a terminal; re-run with --yes to confirm non-interactively."
  fi
  read -r -p "   Continue? (y/N): " REPLY
  [[ "$REPLY" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
fi

BACKUP_DIR="/var/backups/mp-relocate-$(date +%Y%m%d-%H%M%S)"
if [[ $DRY_RUN -eq 0 ]]; then
  mkdir -p "$BACKUP_DIR"
  chmod 0700 "$BACKUP_DIR"   # .env backups carry DB credentials and the encryption key
fi

# ---------------------------------------------------------------------------
# 5) capture queue worker state before touching anything
# ---------------------------------------------------------------------------
step "Queue workers"

QUEUE_INSTANCES=()
if [[ -f "$QUEUE_UNIT" ]]; then
  while IFS= read -r n; do
    [[ -n "$n" ]] && QUEUE_INSTANCES+=("$n")
  done < <(
    {
      systemctl list-units --all --plain --no-legend "${SERVICE_NAME}@*.service" 2>/dev/null | awk '{print $1}'
      ls /etc/systemd/system/multi-user.target.wants/ 2>/dev/null | grep -E "^${SERVICE_NAME}@[0-9]+\.service$" || true
    } | sed -E "s/^${SERVICE_NAME}@([0-9]+)\.service\$/\1/" | grep -E '^[0-9]+$' | sort -n -u
  )

  if [[ ${#QUEUE_INSTANCES[@]} -eq 0 ]]; then
    warn "No existing worker instances found; will start workers 1..$DEFAULT_WORKERS."
    QUEUE_INSTANCES=($(seq 1 "$DEFAULT_WORKERS"))
  else
    info "Existing worker instances: ${QUEUE_INSTANCES[*]}"
  fi

  if [[ $DRY_RUN -eq 0 ]]; then
    for n in "${QUEUE_INSTANCES[@]}"; do
      systemctl stop "${SERVICE_NAME}@${n}.service" 2>/dev/null || true
    done
    ok "Stopped ${#QUEUE_INSTANCES[@]} worker(s) for the rewrite."
  fi
else
  warn "$QUEUE_UNIT not found; the queue template unit has never been created."
  warn "After this script finishes: sudo -u $SYS_USER $PHP_BIN $TARGET/yii queue-service/setup $DEFAULT_WORKERS"
fi

# ---------------------------------------------------------------------------
# 6) systemd units: rewrite paths, never the command
# ---------------------------------------------------------------------------
step "systemd units"

fix_unit() {
  local unit="$1" label="$2" rc=0
  if [[ ! -f "$unit" ]]; then
    warn "$label not found at $unit, skipping."
    return 0
  fi

  # Singleton units: only one install can own them, so every path in them is
  # rewritten. The plan step above already warned if that steals a live install.
  rewrite_paths "$unit" all || rc=$?
  case $rc in
    0) ok "$label: paths repointed at $TARGET." ;;
    2) ok "$label: already correct." ;;
    *) fail "$label: rewrite failed."; return 0 ;;
  esac

  # WorkingDirectory is what makes relative paths in the app resolve; the unit
  # generated by QueueService does not set one.
  if ! grep -qE '^[[:space:]]*WorkingDirectory=' "$unit"; then
    if [[ $DRY_RUN -eq 1 ]]; then
      info "$label: would add WorkingDirectory=$TARGET"
    else
      sed -i "0,/^\[Service\]/s##[Service]\nWorkingDirectory=$TARGET#" "$unit"
      ok "$label: added WorkingDirectory=$TARGET"
    fi
  fi
}

fix_unit "$APP_UNIT"   "multiportal.service"
fix_unit "$QUEUE_UNIT" "${SERVICE_NAME}@.service"

# The queue unit must run as the web user or it will root-own backend/runtime.
if [[ -f "$QUEUE_UNIT" ]] && ! grep -qE "^[[:space:]]*User=${SYS_USER}\$" "$QUEUE_UNIT"; then
  warn "$QUEUE_UNIT does not run as $SYS_USER. Fix it, or regenerate the unit with:"
  warn "  sudo -u $SYS_USER $PHP_BIN $TARGET/yii queue-service/create ${#QUEUE_INSTANCES[@]}"
fi

if [[ $DRY_RUN -eq 0 ]]; then
  systemctl daemon-reload
  ok "systemd daemon reloaded."

  if [[ -f "$APP_UNIT" ]]; then
    if [[ $DISABLE_LEGACY -eq 1 ]]; then
      systemctl stop multiportal.service 2>/dev/null || true
      systemctl disable multiportal.service 2>/dev/null || true
      ok "multiportal.service stopped and disabled."
    elif [[ $APP_UNIT_WAS_ACTIVE -eq 1 ]]; then
      if systemctl restart multiportal.service; then
        ok "multiportal.service restarted (it was running before)."
      else
        fail "multiportal.service failed to restart. See: journalctl -u multiportal.service -n 100 --no-pager"
      fi
    else
      ok "multiportal.service left stopped (it was not running before)."
    fi
  fi

  if [[ -f "$QUEUE_UNIT" ]]; then
    started=0
    for n in "${QUEUE_INSTANCES[@]}"; do
      if systemctl start "${SERVICE_NAME}@${n}.service"; then
        started=$((started + 1))
      else
        fail "worker ${n} failed to start. See: journalctl -u ${SERVICE_NAME}@${n} -n 50 --no-pager"
      fi
    done
    ok "Started ${started}/${#QUEUE_INSTANCES[@]} worker(s)."
  fi
fi

# ---------------------------------------------------------------------------
# 7) cron/index hook
# ---------------------------------------------------------------------------
step "cron hook"

CRON_RE='yii.*cron|cron.*yii'   # matches "./yii cron", "php yii cron/index"
CRON_FIXED=0
CRON_OWNERS=()

fix_crontab_for() {
  local user="$1" cur new expr
  cur="$(mktemp)"; new="$(mktemp)"
  crontab -u "$user" -l >"$cur" 2>/dev/null || true

  if ! grep -qE "$CRON_RE" "$cur"; then
    rm -f "$cur" "$new"
    return 0
  fi

  CRON_OWNERS+=("crontab($user)")
  CRON_FIXED=1
  report_untouched "crontab($user)" "$cur"

  expr="$(sed_expr_for "$cur" stale "$CRON_RE")"
  if [[ -z "$expr" ]]; then
    ok "crontab($user): no stale install path to rewrite."
    rm -f "$cur" "$new"
    return 0
  fi

  # Two stale paths collapsing onto $TARGET would leave duplicate hooks, and a
  # doubled cron/index fires the same tasks twice a minute.
  sed -E "$expr" "$cur" | awk '!/^[[:space:]]*#/ && seen[$0]++ { next } { print }' > "$new"

  if cmp -s "$cur" "$new"; then
    ok "crontab($user): already correct."
  elif [[ $DRY_RUN -eq 1 ]]; then
    diff -u "$cur" "$new" | sed 's/^/     /' || true
  else
    cp -a "$cur" "$BACKUP_DIR/crontab_${user}.bak"
    crontab -u "$user" "$new" && ok "crontab($user): repointed at $TARGET." || fail "crontab($user): install failed."
  fi
  rm -f "$cur" "$new"
}

# Name the live installs referenced by $2 that we are deliberately not touching.
report_untouched() {
  local label="$1" file="$2" p stale
  local -a keep=()
  stale="$(stale_paths_in "$file")"
  while IFS= read -r p; do
    [[ -n "$p" ]] || continue
    grep -qxF "$p" <<<"$stale" && continue
    keep+=("$p")
  done < <(foreign_paths_in "$file")

  case ${#keep[@]} in
    0) ;;
    1|2|3) info "$label: leaving ${keep[*]} alone (other live install(s))." ;;
    *)     info "$label: leaving ${#keep[@]} other live installs alone." ;;
  esac
}

fix_crontab_for root
[[ "$SYS_USER" != "root" ]] && fix_crontab_for "$SYS_USER"

# /etc/cron.d entries
if [[ -d /etc/cron.d ]]; then
  while IFS= read -r f; do
    [[ -n "$f" ]] || continue
    CRON_OWNERS+=("$f")
    CRON_FIXED=1
    report_untouched "$f" "$f"
    rc=0; rewrite_paths "$f" stale "$CRON_RE" || rc=$?
    case $rc in
      0) ok "$f: repointed at $TARGET." ;;
      2) ok "$f: no stale install path to rewrite." ;;
    esac
  done < <(grep -rlE "$CRON_RE" /etc/cron.d 2>/dev/null || true)
fi

if [[ ${#CRON_OWNERS[@]} -gt 1 ]]; then
  warn "The cron/index hook is installed in more than one place:"
  printf '     %s\n' "${CRON_OWNERS[@]}" >&2
  warn "Two runners means cron/index fires twice a minute as two different users,"
  warn "which root-owns backend/runtime and the .cron_check marker. Remove all but one."
fi

if [[ $CRON_FIXED -eq 0 ]]; then
  # Nothing existed anywhere. Install it for the web user, not root, so the
  # marker files and runtime cache stay owned by the user php-fpm runs as.
  info "No cron/index hook found on this host; installing one for $SYS_USER."
  if [[ $DRY_RUN -eq 1 ]]; then
    info "would add: * * * * * cd $TARGET && $PHP_BIN yii cron/index >/dev/null 2>&1"
  else
    cur="$(mktemp)"
    crontab -u "$SYS_USER" -l >"$cur" 2>/dev/null || true
    cp -a "$cur" "$BACKUP_DIR/crontab_${SYS_USER}.bak" 2>/dev/null || true
    printf '* * * * * cd %s && %s yii cron/index >/dev/null 2>&1\n' "$TARGET" "$PHP_BIN" >>"$cur"
    crontab -u "$SYS_USER" "$cur" && ok "Installed cron/index hook for $SYS_USER." || fail "Could not install the cron hook."
    rm -f "$cur"
  fi
fi

# ---------------------------------------------------------------------------
# 8) HOST_NAME rewrite (opt-in)
# ---------------------------------------------------------------------------
if [[ $SET_HOST_NAME -eq 1 && "$HOST_NAME_NEW" != "$HOST_NAME_CUR" ]]; then
  step "HOST_NAME rewrite"
  if [[ $DRY_RUN -eq 1 ]]; then
    info "would set HOST_NAME=\"$HOST_NAME_NEW\" in $ENV_FILE"
  else
    backup_file "$ENV_FILE"
    if grep -qE '^[[:space:]]*(export[[:space:]]+)?HOST_NAME=' "$ENV_FILE"; then
      sed -i -E "s|^([[:space:]]*(export[[:space:]]+)?)HOST_NAME=.*|\1HOST_NAME=\"$(sed_escape_repl "$HOST_NAME_NEW")\"|" "$ENV_FILE"
    else
      printf 'HOST_NAME="%s"\n' "$HOST_NAME_NEW" >>"$ENV_FILE"
    fi
    chown "$SYS_USER:$SYS_USER" "$ENV_FILE"
    chmod 0640 "$ENV_FILE"
    ok "HOST_NAME set to $HOST_NAME_NEW (backup in $BACKUP_DIR)."
    warn "HOST_NAME also drives the API base URL and the WebSocket origin allow-list."
    warn "Confirm DNS + the certificate cover $HOST_NAME_NEW before you trust the portal."
  fi
fi

# ---------------------------------------------------------------------------
# 9) pm2 websocket console server
# ---------------------------------------------------------------------------
step "pm2 websocket server"

if ! command -v pm2 >/dev/null 2>&1; then
  warn "pm2 not installed, skipping. The console proxy will not run without it."
else
  pm2_as() { sudo -H -u "$SYS_USER" PM2_HOME="$PM2_HOME" pm2 "$@"; }

  PM2_TARGET_NAME="${HOST_NAME_NEW:-$HOST_NAME_CUR}"
  if [[ -z "$PM2_TARGET_NAME" ]]; then
    warn "No HOST_NAME, so the pm2 process name is unknown; skipping."
  elif [[ $DRY_RUN -eq 1 ]]; then
    info "would re-register pm2 process '$PM2_TARGET_NAME' from $TARGET/websockets/server.js"
  else
    # pm2's saved dump still holds the OLD script path, so `pm2 restart` would
    # relaunch from the directory that no longer exists. Delete and re-add.
    [[ -n "$HOST_NAME_CUR" ]] && pm2_as delete "$HOST_NAME_CUR" >/dev/null 2>&1 || true
    [[ "$PM2_TARGET_NAME" != "$HOST_NAME_CUR" ]] && pm2_as delete "$PM2_TARGET_NAME" >/dev/null 2>&1 || true

    if (cd "$TARGET" && pm2_as start "$TARGET/websockets/server.js" --name "$PM2_TARGET_NAME" >/dev/null 2>&1); then
      pm2_as save >/dev/null 2>&1 || warn "pm2 save failed; the process will not survive a reboot."
      ok "pm2 process '$PM2_TARGET_NAME' running from $TARGET/websockets/server.js"
    else
      fail "pm2 could not start the websocket server. Check: sudo -u $SYS_USER PM2_HOME=$PM2_HOME pm2 logs $PM2_TARGET_NAME"
    fi
  fi
fi

# ---------------------------------------------------------------------------
# 10) Caddy (opt-in, because a bad Caddyfile takes the whole portal offline)
# ---------------------------------------------------------------------------
step "Caddy"

# One Caddyfile serves every install on the host, so only the stale path (the
# dir that is gone, or the one named by --from) is a candidate here.
CADDY_STALE=()
while IFS= read -r f; do
  [[ -n "$f" ]] || continue
  [[ -n "$(stale_paths_in "$f")" ]] && CADDY_STALE+=("$f") || true
done < <(
  { [[ -f "$CADDYFILE" ]] && printf '%s\n' "$CADDYFILE"
    [[ -d "$CADDY_CONF_D" ]] && find "$CADDY_CONF_D" -type f -name '*.caddy' 2>/dev/null
  } | sort -u
)

if [[ ${#CADDY_STALE[@]} -eq 0 ]]; then
  ok "No stale install paths in the Caddy config."
elif [[ $FIX_CADDY -eq 0 ]]; then
  warn "Stale install paths found in:"
  printf '     %s\n' "${CADDY_STALE[@]}" >&2
  warn "Re-run with --fix-caddy to rewrite, validate and reload them."
else
  for f in "${CADDY_STALE[@]}"; do
    report_untouched "$f" "$f"
    rc=0; rewrite_paths "$f" stale || rc=$?
    [[ $rc -eq 0 ]] && ok "$f: repointed at $TARGET."
  done
  if [[ $DRY_RUN -eq 0 ]]; then
    if ! command -v caddy >/dev/null 2>&1; then
      fail "caddy binary not found; config was rewritten but NOT validated or reloaded."
    elif caddy validate --adapter caddyfile --config "$CADDYFILE" >/dev/null 2>&1; then
      systemctl reload caddy && ok "Caddy validated and reloaded." || fail "Caddy reload failed."
    else
      fail "Caddy config did NOT validate; nothing reloaded. Restore from $BACKUP_DIR and fix by hand."
      caddy validate --adapter caddyfile --config "$CADDYFILE" 2>&1 | sed 's/^/     /' >&2 || true
    fi
  fi
fi

# ---------------------------------------------------------------------------
# 11) ownership of the writable directories
# ---------------------------------------------------------------------------
step "Ownership"

if [[ $DRY_RUN -eq 1 ]]; then
  info "would chown -R $SYS_USER: runtime/log/backup dirs under $TARGET"
else
  for d in backend/runtime console/runtime frontend/runtime logs _backup; do
    [[ -d "$TARGET/$d" ]] && chown -R "$SYS_USER:$SYS_USER" "$TARGET/$d"
  done
  # Marker files written by cron/index.
  for f in .cron_check .post_update .post_update_running .console_server_override; do
    [[ -e "$TARGET/$f" ]] && chown "$SYS_USER:$SYS_USER" "$TARGET/$f"
  done
  [[ -d "$PM2_HOME" ]] && chown -R "$SYS_USER:$SYS_USER" "$PM2_HOME"
  chown "$SYS_USER:$SYS_USER" "$ENV_FILE"; chmod 0640 "$ENV_FILE"
  ok "Runtime, log, backup and pm2 directories owned by $SYS_USER."
fi

# ---------------------------------------------------------------------------
# 12) summary
# ---------------------------------------------------------------------------
step "Summary"

if [[ $DRY_RUN -eq 1 ]]; then
  echo "   Dry run complete. Nothing was changed."
  exit 0
fi

echo "   Install dir: $TARGET"
echo "   Backups:     $BACKUP_DIR"
echo ""
echo "   Verify:"
echo "     sudo systemctl status multiportal.service"
echo "     sudo systemctl status '${SERVICE_NAME}@*.service'"
echo "     sudo -u $SYS_USER $PHP_BIN $TARGET/yii queue-service/status"
echo "     sudo -u $SYS_USER PM2_HOME=$PARENT_DIR/.pm2 pm2 list"
echo "     sudo crontab -u $SYS_USER -l"
echo ""

if [[ ${#FAILURES[@]} -gt 0 ]]; then
  echo "   ${#FAILURES[@]} step(s) failed:" >&2
  printf '     - %s\n' "${FAILURES[@]}" >&2
  echo ""
  echo "   Diagnostics to share:"
  echo "     sudo journalctl -u multiportal.service -n 100 --no-pager"
  echo "     sudo journalctl -u '${SERVICE_NAME}@*' -n 100 --no-pager"
  exit 1
fi

echo "   All steps completed."
