#!/usr/bin/env bash
#
# nginx-provision.sh — Provision an Nginx virtual host (optionally with PHP-FPM
# and WordPress) on a Linux server.
#
# Cross-distro: detects the running OS and optimizes for it instead of assuming
# Debian/Ubuntu. Supported families:
#   - debian  (Debian, Ubuntu, Linux Mint, Pop!_OS, ...)          -> apt-get
#   - rhel    (RHEL, CentOS, Rocky, AlmaLinux, Fedora, Amazon)    -> dnf / yum
#   - arch    (Arch, Manjaro, EndeavourOS, ...)                   -> pacman
#   - suse    (openSUSE, SLES)                                    -> zypper
#   - alpine  (Alpine)                                           -> apk
#
# Features:
#   - Root privilege check
#   - OS/distro detection with per-family package names, service names,
#     web-server user, config layout and PHP-FPM paths
#   - Auto-install of Nginx, PHP-FPM and every dependency the site needs
#     (acl, openssl, curl, tar, DB client, certbot, php-mysql, ...)
#   - Domain name validation
#   - Dedicated Linux system user per site
#   - Custom (or default) document root
#   - Secure ownership + permissions
#   - POSIX ACLs so Nginx can read the site
#   - Dedicated PHP-FPM pool per site, sized from the machine's RAM
#   - Automatic Nginx server-block generation (sites-enabled OR conf.d)
#   - Optional WordPress install (download, DB, wp-config, admin creds)
#   - Optional Let's Encrypt SSL via certbot (with auto-renewal check)
#   - Random password generation + saved credential file
#   - Config testing before reload
#   - Error handling + logging
#
# Usage:
#   sudo ./nginx-provision.sh
#   sudo ./nginx-provision.sh --domain example.com --root /var/www/example.com \
#        --php --wordpress --non-interactive
#
set -Eeuo pipefail

# ----------------------------------------------------------------------------
# Globals / defaults
# ----------------------------------------------------------------------------
SCRIPT_NAME="$(basename "$0")"
LOG_FILE="/var/log/nginx-provision.log"

DOMAIN=""
ROOT_DIR=""
SITE_USER=""
INSTALL_PHP=""
INSTALL_WP=""
INSTALL_SSL=""
SSL_EMAIL=""
ENABLE_LISTING="n"
NON_INTERACTIVE="false"
MANAGE_MODE="false"        # --manage: operate on an already-existing vhost
FORCE="false"             # --force: allow destructive actions non-interactively
BACKUP_DIR=""             # where existing content was backed up (if chosen)
EXISTING_DOCROOT="false"   # set to true when the doc root already has content
VHOST_EXISTED="false"      # set to true when the Nginx server block already exists
MENU_CHOICE=""            # set by the top-level menu

# --- Filled in by detect_os() ------------------------------------------------
OS_ID=""                  # e.g. debian, ubuntu, fedora, rocky, arch, opensuse-leap, alpine
OS_LIKE=""                # ID_LIKE from os-release
OS_FAMILY=""              # debian | rhel | arch | suse | alpine
OS_PRETTY=""              # human-readable name
PKG_MGR=""                # apt-get | dnf | yum | pacman | zypper | apk
WEB_USER=""               # nginx worker user: www-data | nginx | http
WEB_GROUP=""              # nginx worker group
INIT_SYSTEM="systemd"     # systemd | openrc
NGINX_CONF_MODE=""        # sites | confd
NGINX_CONF_DIR="/etc/nginx"
NGINX_SITES_AVAILABLE="/etc/nginx/sites-available"
NGINX_SITES_ENABLED="/etc/nginx/sites-enabled"
NGINX_CONFD="/etc/nginx/conf.d"
NOLOGIN_SHELL="/usr/sbin/nologin"

# Filled in by install_php()
PHP_VERSION=""
PHP_SOCK=""
PHP_FPM_POOL_DIR=""
PHP_FPM_SERVICE=""
PHP_SOCK_DIR="/run/php"

# ----------------------------------------------------------------------------
# Logging helpers
# ----------------------------------------------------------------------------
_ts()   { date '+%Y-%m-%d %H:%M:%S'; }
log()   { printf '[%s] %s\n' "$(_ts)" "$*" | tee -a "$LOG_FILE"; }
info()  { log "INFO:  $*"; }
warn()  { log "WARN:  $*" >&2; }
error() { log "ERROR: $*" >&2; }
die()   { error "$*"; exit 1; }

# Trap any uncaught error and report the failing line.
trap 'error "Failed at line $LINENO (exit $?). See $LOG_FILE for details."' ERR

# ----------------------------------------------------------------------------
# Argument parsing
# ----------------------------------------------------------------------------
usage() {
  cat <<EOF
$SCRIPT_NAME — provision an Nginx site (cross-distro).

Options:
  -d, --domain DOMAIN     Domain name (e.g. example.com)
  -r, --root DIR          Document root (default: /var/www/DOMAIN/public_html)
  -u, --user USER         System user to own the site (default: derived from domain)
      --php               Install/enable PHP-FPM for the site
      --wordpress         Install WordPress (implies --php)
      --ssl               Obtain a Let's Encrypt certificate via certbot
      --email EMAIL       Email for Let's Encrypt (registration + expiry notices)
      --listing           Enable directory autoindex
      --manage            Manage an EXISTING vhost (pick one, then e.g. install
                          WordPress into it — see below)
      --force             Allow destructive actions without prompting
                          (only meaningful with --manage --non-interactive)
      --non-interactive   Do not prompt; require all needed flags
  -h, --help              Show this help

Any option left unset (in interactive mode) will be prompted for.

Manage mode (--manage):
  Lets you act on a vhost that already exists. You pick the site, and the
  script reads its real document root from the Nginx config. Installing
  WordPress in this mode REPLACES the doc root contents with a fresh install,
  after first warning you and offering to back up the current files elsewhere.
    sudo $SCRIPT_NAME --manage
    sudo $SCRIPT_NAME --manage --domain example.com --wordpress
EOF
}

parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -d|--domain)      DOMAIN="${2:-}"; shift 2 ;;
      -r|--root)        ROOT_DIR="${2:-}"; shift 2 ;;
      -u|--user)        SITE_USER="${2:-}"; shift 2 ;;
      --php)            INSTALL_PHP="y"; shift ;;
      --wordpress)      INSTALL_WP="y"; INSTALL_PHP="y"; shift ;;
      --ssl)            INSTALL_SSL="y"; shift ;;
      --email)          SSL_EMAIL="${2:-}"; shift 2 ;;
      --listing)        ENABLE_LISTING="y"; shift ;;
      --manage)         MANAGE_MODE="true"; shift ;;
      --force)          FORCE="true"; shift ;;
      --non-interactive) NON_INTERACTIVE="true"; shift ;;
      -h|--help)        usage; exit 0 ;;
      *) die "Unknown argument: $1 (see --help)" ;;
    esac
  done
}

# ----------------------------------------------------------------------------
# OS / distro detection — everything downstream keys off what we set here.
# ----------------------------------------------------------------------------
detect_os() {
  if [[ -r /etc/os-release ]]; then
    # shellcheck disable=SC1091
    . /etc/os-release
    OS_ID="${ID:-}"
    OS_LIKE="${ID_LIKE:-}"
    OS_PRETTY="${PRETTY_NAME:-${NAME:-$OS_ID}}"
  else
    OS_ID="unknown"; OS_PRETTY="unknown"
  fi

  # Classify into a family using ID first, then ID_LIKE as a fallback so
  # derivatives (Mint, Pop!_OS, Rocky, Manjaro, ...) map correctly.
  local probe=" $OS_ID $OS_LIKE "
  case "$probe" in
    *" debian "*|*" ubuntu "*)                               OS_FAMILY="debian" ;;
    *" rhel "*|*" fedora "*|*" centos "*|*" rocky "*|*" almalinux "*|*" amzn "*) OS_FAMILY="rhel" ;;
    *" arch "*|*" archlinux "*)                              OS_FAMILY="arch" ;;
    *" suse "*|*" opensuse "*|*" sles "*)                    OS_FAMILY="suse" ;;
    *" alpine "*)                                            OS_FAMILY="alpine" ;;
    *)
      # Last-ditch: sniff for a package manager.
      if   command -v apt-get >/dev/null 2>&1; then OS_FAMILY="debian"
      elif command -v dnf     >/dev/null 2>&1; then OS_FAMILY="rhel"
      elif command -v yum     >/dev/null 2>&1; then OS_FAMILY="rhel"
      elif command -v pacman  >/dev/null 2>&1; then OS_FAMILY="arch"
      elif command -v zypper  >/dev/null 2>&1; then OS_FAMILY="suse"
      elif command -v apk     >/dev/null 2>&1; then OS_FAMILY="alpine"
      else die "Unsupported/undetected OS. Only Linux with apt/dnf/yum/pacman/zypper/apk is supported."
      fi
      ;;
  esac

  # Per-family knobs.
  case "$OS_FAMILY" in
    debian)
      PKG_MGR="apt-get"
      WEB_USER="www-data"; WEB_GROUP="www-data"
      NGINX_CONF_MODE="sites"
      NOLOGIN_SHELL="/usr/sbin/nologin"
      PHP_SOCK_DIR="/run/php"
      ;;
    rhel)
      if command -v dnf >/dev/null 2>&1; then PKG_MGR="dnf"; else PKG_MGR="yum"; fi
      WEB_USER="nginx"; WEB_GROUP="nginx"
      NGINX_CONF_MODE="confd"
      NOLOGIN_SHELL="/sbin/nologin"
      PHP_SOCK_DIR="/run/php-fpm"
      ;;
    arch)
      PKG_MGR="pacman"
      WEB_USER="http"; WEB_GROUP="http"
      NGINX_CONF_MODE="confd"
      NOLOGIN_SHELL="/usr/bin/nologin"
      PHP_SOCK_DIR="/run/php-fpm"
      ;;
    suse)
      PKG_MGR="zypper"
      WEB_USER="nginx"; WEB_GROUP="nginx"
      NGINX_CONF_MODE="confd"
      NOLOGIN_SHELL="/sbin/nologin"
      PHP_SOCK_DIR="/run/php-fpm"
      ;;
    alpine)
      PKG_MGR="apk"
      WEB_USER="nginx"; WEB_GROUP="nginx"
      NGINX_CONF_MODE="confd"
      NOLOGIN_SHELL="/sbin/nologin"
      PHP_SOCK_DIR="/run/php-fpm"
      INIT_SYSTEM="openrc"
      ;;
  esac

  # systemd vs openrc (Alpine and a few minimal images).
  if [[ "$INIT_SYSTEM" != "openrc" ]]; then
    if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
      INIT_SYSTEM="systemd"
    elif command -v rc-service >/dev/null 2>&1; then
      INIT_SYSTEM="openrc"
    fi
  fi

  # A usable nologin shell must exist; fall back to whatever is present.
  if [[ ! -x "$NOLOGIN_SHELL" ]]; then
    if   [[ -x /usr/sbin/nologin ]]; then NOLOGIN_SHELL="/usr/sbin/nologin"
    elif [[ -x /sbin/nologin     ]]; then NOLOGIN_SHELL="/sbin/nologin"
    elif [[ -x /usr/bin/nologin  ]]; then NOLOGIN_SHELL="/usr/bin/nologin"
    else NOLOGIN_SHELL="/bin/false"; fi
  fi

  info "Detected OS: ${OS_PRETTY} (family=${OS_FAMILY}, pkg=${PKG_MGR}, web-user=${WEB_USER}, init=${INIT_SYSTEM}, nginx=${NGINX_CONF_MODE})."
}

# ----------------------------------------------------------------------------
# Package-manager abstraction
# ----------------------------------------------------------------------------
PKG_UPDATED="false"
pkg_update() {
  [[ "$PKG_UPDATED" == "true" ]] && return 0
  info "Refreshing package metadata (${PKG_MGR})..."
  case "$PKG_MGR" in
    apt-get) apt-get update -y                    >>"$LOG_FILE" 2>&1 ;;
    dnf)     dnf -y makecache                      >>"$LOG_FILE" 2>&1 || true ;;
    yum)     yum -y makecache                      >>"$LOG_FILE" 2>&1 || true ;;
    pacman)  pacman -Sy --noconfirm                >>"$LOG_FILE" 2>&1 ;;
    zypper)  zypper --non-interactive refresh      >>"$LOG_FILE" 2>&1 ;;
    apk)     apk update                            >>"$LOG_FILE" 2>&1 ;;
  esac
  PKG_UPDATED="true"
}

pkg_install() {
  # pkg_install <pkg> [pkg...]
  [[ $# -gt 0 ]] || return 0
  pkg_update
  info "Installing package(s): $*"
  case "$PKG_MGR" in
    apt-get) DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" >>"$LOG_FILE" 2>&1 ;;
    dnf)     dnf install -y "$@"                                    >>"$LOG_FILE" 2>&1 ;;
    yum)     yum install -y "$@"                                    >>"$LOG_FILE" 2>&1 ;;
    pacman)  pacman -S --noconfirm --needed "$@"                    >>"$LOG_FILE" 2>&1 ;;
    zypper)  zypper --non-interactive install -y "$@"              >>"$LOG_FILE" 2>&1 ;;
    apk)     apk add "$@"                                           >>"$LOG_FILE" 2>&1 ;;
  esac
}

pkg_is_installed() {
  # pkg_is_installed <pkg>  -> 0 if the package is installed
  local p="$1"
  case "$PKG_MGR" in
    apt-get) dpkg -s "$p"        >/dev/null 2>&1 ;;
    dnf|yum) rpm -q "$p"         >/dev/null 2>&1 ;;
    pacman)  pacman -Qq "$p"     >/dev/null 2>&1 ;;
    zypper)  rpm -q "$p"         >/dev/null 2>&1 ;;
    apk)     apk info -e "$p"    >/dev/null 2>&1 ;;
    *)       return 1 ;;
  esac
}

# Ensure a package is installed only if it (or its providing binary) is missing.
ensure_pkg() {
  # ensure_pkg <pkg> [check-binary]
  local pkg="$1" bin="${2:-}"
  if [[ -n "$bin" ]] && command -v "$bin" >/dev/null 2>&1; then return 0; fi
  if pkg_is_installed "$pkg"; then return 0; fi
  pkg_install "$pkg"
}

# Map a logical dependency to the right package name(s) for this family, then
# install if missing. This is where per-OS naming differences live.
ensure_dep() {
  # ensure_dep <logical-name>
  case "$1" in
    nginx)
      ensure_pkg nginx nginx ;;
    acl)
      # provides setfacl
      case "$OS_FAMILY" in
        *) ensure_pkg acl setfacl ;;
      esac ;;
    openssl)  ensure_pkg openssl openssl ;;
    curl)     ensure_pkg curl curl ;;
    tar)      ensure_pkg tar tar ;;
    ca-certs)
      case "$OS_FAMILY" in
        debian|rhel|suse) ensure_pkg ca-certificates ;;
        arch)             ensure_pkg ca-certificates ;;
        alpine)           ensure_pkg ca-certificates ;;
      esac ;;
    useradd)
      # shadow utilities (Alpine ships busybox adduser; install shadow for useradd)
      if ! command -v useradd >/dev/null 2>&1; then
        case "$OS_FAMILY" in
          alpine) ensure_pkg shadow useradd ;;
          *)      ensure_pkg shadow-utils useradd ;;
        esac
      fi ;;
    mysql-client)
      if command -v mysql >/dev/null 2>&1 || command -v mariadb >/dev/null 2>&1; then return 0; fi
      case "$OS_FAMILY" in
        debian) ensure_pkg default-mysql-client mysql || ensure_pkg mariadb-client mysql ;;
        rhel)   ensure_pkg mariadb mysql || ensure_pkg mysql mysql ;;
        arch)   ensure_pkg mariadb-clients mysql ;;
        suse)   ensure_pkg mariadb-client mysql || ensure_pkg mariadb-tools mysql ;;
        alpine) ensure_pkg mariadb-client mysql ;;
      esac ;;
    db-server)
      case "$OS_FAMILY" in
        debian) ensure_pkg mariadb-server mariadbd || ensure_pkg default-mysql-server ;;
        rhel)   ensure_pkg mariadb-server ;;
        arch)   ensure_pkg mariadb ;;
        suse)   ensure_pkg mariadb ;;
        alpine) ensure_pkg mariadb ;;
      esac ;;
    php-fpm)
      case "$OS_FAMILY" in
        debian) ensure_pkg php-fpm php ;;
        rhel)   ensure_pkg php-fpm php ;;
        arch)   ensure_pkg php-fpm php ;;
        suse)   ensure_pkg php-fpm php || ensure_pkg php8-fpm php ;;
        alpine) ensure_pkg php-fpm php || ensure_pkg php83-fpm php ;;
      esac ;;
    php-mysql)
      case "$OS_FAMILY" in
        debian) ensure_pkg php-mysql ;;
        rhel)   ensure_pkg php-mysqlnd ;;
        arch)   : ;;  # pdo_mysql/mysqli ship with the arch 'php' package
        suse)   ensure_pkg php-mysql || ensure_pkg php8-mysql ;;
        alpine) ensure_pkg php-pdo_mysql || ensure_pkg php83-pdo_mysql || true
                ensure_pkg php-mysqli    || ensure_pkg php83-mysqli    || true ;;
      esac ;;
    php-extras)
      # Extras WordPress likes: gd, curl, xml, mbstring, zip, intl, opcache.
      case "$OS_FAMILY" in
        debian) pkg_install php-gd php-curl php-xml php-mbstring php-zip php-intl php-opcache 2>/dev/null || true ;;
        rhel)   pkg_install php-gd php-xml php-mbstring php-json php-opcache 2>/dev/null || true ;;
        arch)   : ;;  # bundled in arch 'php'
        suse)   pkg_install php-gd php-curl php-xmlreader php-mbstring php-zip php-opcache 2>/dev/null || true ;;
        alpine) pkg_install php-gd php-curl php-xml php-mbstring php-zip php-opcache php-session php-fileinfo php-dom 2>/dev/null || true ;;
      esac ;;
    certbot)
      case "$OS_FAMILY" in
        debian) ensure_pkg certbot certbot; ensure_pkg python3-certbot-nginx ;;
        rhel)
          # certbot lives in EPEL on RHEL-likes (Fedora has it directly).
          if [[ "$OS_ID" != "fedora" ]]; then ensure_pkg epel-release || true; fi
          ensure_pkg certbot certbot; ensure_pkg python3-certbot-nginx || true ;;
        arch)   ensure_pkg certbot certbot; ensure_pkg certbot-nginx ;;
        suse)   ensure_pkg certbot certbot; ensure_pkg python3-certbot-nginx || true ;;
        alpine) ensure_pkg certbot certbot; ensure_pkg certbot-nginx || true ;;
      esac ;;
    *) warn "ensure_dep: unknown dependency '$1'" ;;
  esac
}

# ----------------------------------------------------------------------------
# Service abstraction (systemd / openrc)
# ----------------------------------------------------------------------------
svc_restart() {
  local s="$1"
  if [[ "$INIT_SYSTEM" == "systemd" ]]; then
    systemctl restart "$s"
  else
    rc-service "$s" restart
  fi
}
svc_reload() {
  local s="$1"
  if [[ "$INIT_SYSTEM" == "systemd" ]]; then
    systemctl reload "$s" 2>/dev/null || systemctl restart "$s"
  else
    rc-service "$s" reload 2>/dev/null || rc-service "$s" restart
  fi
}
svc_enable_now() {
  local s="$1"
  if [[ "$INIT_SYSTEM" == "systemd" ]]; then
    systemctl enable --now "$s" >>"$LOG_FILE" 2>&1 || systemctl restart "$s" || true
  else
    rc-update add "$s" default >>"$LOG_FILE" 2>&1 || true
    rc-service "$s" start        >>"$LOG_FILE" 2>&1 || rc-service "$s" restart || true
  fi
}

# ----------------------------------------------------------------------------
# Preconditions
# ----------------------------------------------------------------------------
require_root() {
  [[ "$(id -u)" -eq 0 ]] || die "This script must be run as root (use sudo)."
}

# Install (if missing) the base tools the script itself relies on, plus Nginx.
check_dependencies() {
  ensure_dep useradd
  ensure_dep openssl
  ensure_dep acl
  ensure_dep ca-certs
  ensure_dep nginx

  # Hard requirements after the install attempt.
  command -v nginx   >/dev/null 2>&1 || die "Nginx is required but could not be installed."
  command -v openssl >/dev/null 2>&1 || die "openssl is required but could not be installed."
  command -v useradd >/dev/null 2>&1 || die "useradd is required but could not be installed."
  command -v setfacl >/dev/null 2>&1 || warn "setfacl (acl) unavailable; ACL steps will be skipped."

  # Nginx layout dirs.
  if [[ "$NGINX_CONF_MODE" == "sites" ]]; then
    mkdir -p "$NGINX_SITES_AVAILABLE" "$NGINX_SITES_ENABLED"
  else
    mkdir -p "$NGINX_CONFD"
  fi

  # Make sure nginx is enabled to start on boot.
  svc_enable_now nginx
}

# Make sure a MariaDB/MySQL *server* is installed and running before we try to
# create the WordPress database. The scripts otherwise only install the client,
# which is why a fresh box fails with "Can't connect to local MySQL server
# through socket". Runs as root, so it authenticates via the unix socket.
ensure_database_server() {
  if ! command -v mysqld >/dev/null 2>&1 && ! command -v mariadbd >/dev/null 2>&1 \
     && ! pkg_is_installed mariadb-server && ! pkg_is_installed default-mysql-server \
     && ! pkg_is_installed mysql-server; then
    info "No database server found; installing MariaDB server..."
    ensure_dep db-server
  fi

  # Some distros (notably Arch) don't initialize the data dir automatically.
  if [[ ! -d /var/lib/mysql/mysql ]] && command -v mariadb-install-db >/dev/null 2>&1; then
    info "Initializing MariaDB data directory..."
    mariadb-install-db --user=mysql --basedir=/usr --datadir=/var/lib/mysql >>"$LOG_FILE" 2>&1 || true
  fi

  # Start + enable whichever service name this distro uses.
  local svc
  for svc in mariadb mysqld mysql; do
    if [[ "$INIT_SYSTEM" == "systemd" ]]; then
      if systemctl list-unit-files 2>/dev/null | grep -q "^${svc}\.service"; then
        svc_enable_now "$svc"; break
      fi
    else
      if [[ -x "/etc/init.d/${svc}" ]]; then svc_enable_now "$svc"; break; fi
    fi
  done

  # Wait (bounded) for the server socket to accept connections.
  local i
  for i in $(seq 1 30); do
    if mysqladmin ping >/dev/null 2>&1 || mysql -e 'SELECT 1' >/dev/null 2>&1 \
       || mariadb -e 'SELECT 1' >/dev/null 2>&1; then
      info "Database server is up."
      return 0
    fi
    sleep 1
  done
  die "Database server did not become ready; check its service status/logs."
}

# ----------------------------------------------------------------------------
# Prompt helpers (respect --non-interactive)
# ----------------------------------------------------------------------------
prompt() {
  # prompt <varname> <message> [default]
  local __var="$1" __msg="$2" __default="${3:-}" __reply=""
  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    [[ -n "${!__var:-}" ]] && return 0
    [[ -n "$__default" ]] && { printf -v "$__var" '%s' "$__default"; return 0; }
    die "Missing required value for '$__var' in non-interactive mode."
  fi
  [[ -n "${!__var:-}" ]] && return 0   # already provided via flag
  if [[ -n "$__default" ]]; then
    read -r -p "$__msg [$__default]: " __reply
    __reply="${__reply:-$__default}"
  else
    read -r -p "$__msg: " __reply
  fi
  printf -v "$__var" '%s' "$__reply"
}

prompt_yn() {
  # prompt_yn <varname> <message>
  local __var="$1" __msg="$2" __reply=""
  [[ -n "${!__var:-}" ]] && return 0
  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    printf -v "$__var" '%s' "n"; return 0
  fi
  read -r -p "$__msg (y/n): " __reply
  case "$__reply" in
    [yY]|[yY][eE][sS]) printf -v "$__var" '%s' "y" ;;
    *)                 printf -v "$__var" '%s' "n" ;;
  esac
}

# ----------------------------------------------------------------------------
# Validation
# ----------------------------------------------------------------------------
validate_domain() {
  local d="$1"
  # RFC-ish: labels of a-z 0-9 hyphen, 1-63 chars, at least one dot, valid TLD.
  [[ "$d" =~ ^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$ ]] \
    || die "Invalid domain name: '$d'"
}

# ----------------------------------------------------------------------------
# Site user
# ----------------------------------------------------------------------------
derive_user() {
  # web_<first label of domain>, sanitized, max 32 chars.
  local base="${DOMAIN%%.*}"
  base="$(echo "$base" | tr -cd 'a-z0-9')"
  echo "web_${base}" | cut -c1-32
}

create_site_user() {
  if id "$SITE_USER" >/dev/null 2>&1; then
    # User already exists: reuse it AS-IS. We never re-run useradd or usermod,
    # so nothing about the existing account (home dir, shell, uid, groups,
    # password) is touched or overwritten. The site simply gets assigned to it.
    local existing_home existing_shell
    existing_home="$(getent passwd "$SITE_USER" | cut -d: -f6)"
    existing_shell="$(getent passwd "$SITE_USER" | cut -d: -f7)"
    info "User '$SITE_USER' already exists; reusing it unchanged " \
         "(home: ${existing_home:-?}, shell: ${existing_shell:-?})."
  else
    info "Creating new system user '$SITE_USER'..."
    useradd --system --home-dir "$ROOT_DIR" --no-create-home \
            --shell "$NOLOGIN_SHELL" "$SITE_USER"
  fi
}

# ----------------------------------------------------------------------------
# Document root, ownership, permissions, ACLs
# ----------------------------------------------------------------------------
apply_acls() {
  # apply_acls <path>  — additive read/traverse for WEB_USER + SITE_USER.
  command -v setfacl >/dev/null 2>&1 || return 0
  local target="$1"
  setfacl -R  -m "u:${WEB_USER}:rX" -m "u:${SITE_USER}:rX" "$target" || \
    warn "Could not apply ACLs to $target; check read access."
  setfacl -R -d -m "u:${WEB_USER}:rX" -m "u:${SITE_USER}:rX" "$target" || true
}

setup_docroot() {
  # Detect whether the doc root already exists with content. If so, we treat it
  # as an existing site and DO NOT rewrite ownership/permissions of what's there
  # — we only make sure Nginx can read it (additive ACLs). This preserves any
  # existing files, owners, and modes exactly as they are.
  if [[ -d "$ROOT_DIR" ]] && [[ -n "$(ls -A "$ROOT_DIR" 2>/dev/null)" ]]; then
    EXISTING_DOCROOT="true"
    info "Document root $ROOT_DIR already exists and is not empty; " \
         "preserving existing content, owners and permissions."

    # Additive only: grant read/traverse without changing existing ownership or
    # modes. We grant BOTH the Nginx worker (WEB_USER) AND the site user, which
    # is the account the dedicated PHP-FPM pool runs as — with FastCGI it's the
    # FPM worker, not Nginx, that actually opens the .php file, so it must be
    # able to read it. Missing this is the classic PHP-FPM "File not found".
    apply_acls "$ROOT_DIR"

    # Ensure every parent dir is traversable so both users can reach the root,
    # without touching the root's own contents.
    if command -v setfacl >/dev/null 2>&1; then
      local p="$ROOT_DIR"
      while [[ "$p" != "/" && -n "$p" ]]; do
        setfacl -m "u:${WEB_USER}:rX" -m "u:${SITE_USER}:rX" "$p" 2>/dev/null || true
        p="$(dirname "$p")"
      done
    fi
    info "Existing doc root left intact; ${WEB_USER} + ${SITE_USER} read access ensured."
    return 0
  fi

  # Fresh doc root: create it and apply the secure baseline.
  EXISTING_DOCROOT="false"
  info "Creating new document root at $ROOT_DIR"
  mkdir -p "$ROOT_DIR"

  # Ownership: site user owns files; group WEB_GROUP for shared access.
  chown -R "$SITE_USER:$WEB_GROUP" "$ROOT_DIR"

  # Permissions: dirs 750, files 640; new files inherit the group (setgid).
  find "$ROOT_DIR" -type d -exec chmod 2750 {} +
  find "$ROOT_DIR" -type f -exec chmod 640  {} +

  # ACLs so the Nginx worker (WEB_USER) and the PHP-FPM pool user (SITE_USER)
  # can traverse/read, and defaults apply to anything created later. Granting
  # SITE_USER is what lets PHP-FPM open .php files (avoids "File not found").
  apply_acls "$ROOT_DIR"
  info "Ownership, permissions and ACLs applied."
}

# ----------------------------------------------------------------------------
# PHP
# ----------------------------------------------------------------------------
detect_php_version() {
  if command -v php >/dev/null 2>&1; then
    php -v | head -n1 | grep -oE '[0-9]+\.[0-9]+' | head -n1
  else
    echo ""
  fi
}

# Locate the PHP-FPM pool dir, service name and socket dir for this OS. Paths
# vary a lot between families, so we probe rather than hard-code.
detect_php_fpm_paths() {
  local ver="$1"

  # Pool directory: first existing candidate wins.
  local cand
  local candidates=(
    "/etc/php/${ver}/fpm/pool.d"        # Debian/Ubuntu
    "/etc/php-fpm.d"                    # RHEL/Fedora
    "/etc/php/php-fpm.d"               # Arch
    "/etc/php${ver}/php-fpm.d"          # Alpine (php83 -> /etc/php83)
    "/etc/php${ver%%.*}/fpm/php-fpm.d"   # openSUSE (php8 -> /etc/php8/fpm)
    "/etc/php/fpm/php-fpm.d"
  )
  PHP_FPM_POOL_DIR=""
  for cand in "${candidates[@]}"; do
    [[ -d "$cand" ]] && { PHP_FPM_POOL_DIR="$cand"; break; }
  done
  # If none exist yet (fresh install may create them lazily), pick the family default.
  if [[ -z "$PHP_FPM_POOL_DIR" ]]; then
    case "$OS_FAMILY" in
      debian) PHP_FPM_POOL_DIR="/etc/php/${ver}/fpm/pool.d" ;;
      rhel)   PHP_FPM_POOL_DIR="/etc/php-fpm.d" ;;
      arch)   PHP_FPM_POOL_DIR="/etc/php/php-fpm.d" ;;
      suse)   PHP_FPM_POOL_DIR="/etc/php${ver%%.*}/fpm/php-fpm.d" ;;
      alpine) PHP_FPM_POOL_DIR="/etc/php${ver}/php-fpm.d" ;;
    esac
    mkdir -p "$PHP_FPM_POOL_DIR" 2>/dev/null || true
  fi

  # Service name: Debian uses phpX.Y-fpm; everyone else uses php-fpm (Alpine
  # uses phpXY-fpm under openrc).
  PHP_FPM_SERVICE=""
  local svc_candidates=()
  case "$OS_FAMILY" in
    debian) svc_candidates=("php${ver}-fpm" "php-fpm") ;;
    alpine) svc_candidates=("php-fpm${ver}" "php${ver}-fpm" "php-fpm") ;;
    *)      svc_candidates=("php-fpm" "php${ver}-fpm") ;;
  esac
  local s
  for s in "${svc_candidates[@]}"; do
    if [[ "$INIT_SYSTEM" == "systemd" ]]; then
      if systemctl list-unit-files 2>/dev/null | grep -q "^${s}\.service"; then
        PHP_FPM_SERVICE="$s"; break
      fi
    else
      if [[ -x "/etc/init.d/${s}" ]]; then PHP_FPM_SERVICE="$s"; break; fi
    fi
  done
  [[ -n "$PHP_FPM_SERVICE" ]] || PHP_FPM_SERVICE="${svc_candidates[0]}"

  # Socket directory must exist and be readable by the nginx worker.
  mkdir -p "$PHP_SOCK_DIR" 2>/dev/null || true
  PHP_SOCK="${PHP_SOCK_DIR}/php${ver}-${SITE_USER}.sock"
}

install_php() {
  local ver
  ver="$(detect_php_version)"
  if [[ -z "$ver" ]]; then
    info "PHP not found; installing PHP-FPM..."
    ensure_dep php-fpm
    ver="$(detect_php_version)"
  else
    info "PHP $ver already present; ensuring PHP-FPM is installed..."
    ensure_dep php-fpm
  fi
  [[ -n "$ver" ]] || die "Failed to detect/install PHP."
  PHP_VERSION="$ver"

  detect_php_fpm_paths "$PHP_VERSION"
  [[ -d "$PHP_FPM_POOL_DIR" ]] || die "PHP-FPM pool dir not found/creatable: $PHP_FPM_POOL_DIR"

  # Make sure the FPM service is enabled and running.
  svc_enable_now "$PHP_FPM_SERVICE"
  info "Using PHP $PHP_VERSION (pool dir: $PHP_FPM_POOL_DIR, service: $PHP_FPM_SERVICE, sock: $PHP_SOCK)"
}

# Size the pool from available RAM so we don't over/under-commit on small or
# large boxes. Roughly 40 MB per PHP worker, using ~half of total RAM.
calc_max_children() {
  local total_kb per_child_mb budget_mb children
  total_kb="$(awk '/MemTotal/{print $2; exit}' /proc/meminfo 2>/dev/null || echo 1048576)"
  per_child_mb=40
  budget_mb=$(( total_kb / 1024 / 2 ))          # half of total RAM
  children=$(( budget_mb / per_child_mb ))
  (( children < 5  )) && children=5
  (( children > 50 )) && children=50
  echo "$children"
}

create_php_pool() {
  local pool_file="${PHP_FPM_POOL_DIR}/${DOMAIN}.conf"
  [[ -d "$PHP_FPM_POOL_DIR" ]] || die "PHP-FPM pool dir not found: $PHP_FPM_POOL_DIR"

  local max_children start min_spare max_spare
  max_children="$(calc_max_children)"
  start=$(( max_children / 4 ));      (( start < 2 )) && start=2
  min_spare=$(( start ));             (( min_spare < 1 )) && min_spare=1
  max_spare=$(( max_children / 2 ));  (( max_spare < min_spare )) && max_spare=$(( min_spare + 1 ))

  info "Creating dedicated PHP-FPM pool: $pool_file (max_children=${max_children}, tuned for this host's RAM)"
  cat > "$pool_file" <<EOF
; Dedicated PHP-FPM pool for ${DOMAIN}
[${DOMAIN}]
user = ${SITE_USER}
group = ${SITE_USER}
listen = ${PHP_SOCK}
listen.owner = ${WEB_USER}
listen.group = ${WEB_GROUP}
listen.mode = 0660

pm = dynamic
pm.max_children = ${max_children}
pm.start_servers = ${start}
pm.min_spare_servers = ${min_spare}
pm.max_spare_servers = ${max_spare}
pm.max_requests = 500

php_admin_value[error_log] = /var/log/php/${DOMAIN}.error.log
php_admin_flag[log_errors] = on
chdir = /
EOF

  mkdir -p /var/log/php
  chown "${WEB_USER}:${WEB_GROUP}" /var/log/php 2>/dev/null || true
  svc_restart "$PHP_FPM_SERVICE"
  info "PHP-FPM pool active."
}

# ----------------------------------------------------------------------------
# Nginx vhost path helpers (abstract sites-enabled vs conf.d)
# ----------------------------------------------------------------------------
# The path we write a fresh config to.
vhost_src() {
  if [[ "$NGINX_CONF_MODE" == "confd" ]]; then
    echo "${NGINX_CONFD}/${DOMAIN}.conf"
  else
    echo "${NGINX_SITES_AVAILABLE}/${DOMAIN}"
  fi
}

# Find an existing config for DOMAIN (enabled OR disabled); echoes path or "".
vhost_find() {
  if [[ "$NGINX_CONF_MODE" == "confd" ]]; then
    local p
    for p in "${NGINX_CONFD}/${DOMAIN}.conf" "${NGINX_CONFD}/${DOMAIN}.conf.disabled"; do
      [[ -f "$p" ]] && { echo "$p"; return 0; }
    done
  else
    [[ -f "${NGINX_SITES_AVAILABLE}/${DOMAIN}" ]] && echo "${NGINX_SITES_AVAILABLE}/${DOMAIN}"
  fi
  # Always succeed: "no match" is a valid result, not an error. Returning
  # non-zero here would trip `set -e` on `x="$(vhost_find)"` assignments.
  return 0
}

vhost_exists() { [[ -n "$(vhost_find)" ]]; }

vhost_is_enabled() {
  if [[ "$NGINX_CONF_MODE" == "confd" ]]; then
    [[ -f "${NGINX_CONFD}/${DOMAIN}.conf" ]]
  else
    [[ -L "${NGINX_SITES_ENABLED}/${DOMAIN}" ]]
  fi
}

vhost_enable() {
  if [[ "$NGINX_CONF_MODE" == "confd" ]]; then
    [[ -f "${NGINX_CONFD}/${DOMAIN}.conf.disabled" ]] && \
      mv -f "${NGINX_CONFD}/${DOMAIN}.conf.disabled" "${NGINX_CONFD}/${DOMAIN}.conf"
  else
    ln -sf "${NGINX_SITES_AVAILABLE}/${DOMAIN}" "${NGINX_SITES_ENABLED}/${DOMAIN}"
  fi
}

vhost_disable() {
  if [[ "$NGINX_CONF_MODE" == "confd" ]]; then
    [[ -f "${NGINX_CONFD}/${DOMAIN}.conf" ]] && \
      mv -f "${NGINX_CONFD}/${DOMAIN}.conf" "${NGINX_CONFD}/${DOMAIN}.conf.disabled"
  else
    rm -f "${NGINX_SITES_ENABLED}/${DOMAIN}"
  fi
}

# ----------------------------------------------------------------------------
# Nginx server block
# ----------------------------------------------------------------------------
write_nginx_conf() {
  local conf; conf="$(vhost_src)"
  local existing; existing="$(vhost_find)"

  # If the server block already exists, do NOT overwrite it. It may have been
  # edited by certbot (SSL) or by hand, and clobbering it would break the site.
  # We reuse it as-is and let the rest of the run (e.g. WordPress) continue.
  if [[ -n "$existing" ]]; then
    VHOST_EXISTED="true"
    info "Nginx server block already exists ($existing); reusing it unchanged."
    vhost_enable   # harmless if already enabled
    return 0
  fi

  info "Writing Nginx server block: $conf"

  # Build optional fragments.
  local php_block="" wp_block="" autoindex="off"
  [[ "$ENABLE_LISTING" == "y" ]] && autoindex="on"

  # Portable PHP handler — does NOT rely on Debian's snippets/fastcgi-php.conf,
  # which doesn't exist on RHEL/Arch/SUSE/Alpine. fastcgi_params is universal.
  if [[ "$INSTALL_PHP" == "y" ]]; then
    php_block=$(cat <<EOF

    location ~ \.php\$ {
        try_files \$uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)\$;
        fastcgi_pass unix:${PHP_SOCK};
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
        fastcgi_param PATH_INFO \$fastcgi_path_info;
    }
EOF
)
  fi

  if [[ "$INSTALL_WP" == "y" ]]; then
    wp_block=$(cat <<'EOF'

    # WordPress permalinks
    location / {
        try_files $uri $uri/ /index.php?$args;
    }
    # Deny access to sensitive files
    location = /xmlrpc.php { deny all; }
    location ~* /(?:\.htaccess|wp-config\.php)$ { deny all; }
EOF
)
  fi

  cat > "$conf" <<EOF
server {
    listen 80;
    listen [::]:80;
    server_name ${DOMAIN} www.${DOMAIN};

    root ${ROOT_DIR};
    index index.php index.html index.htm;

    autoindex ${autoindex};

    access_log /var/log/nginx/${DOMAIN}.access.log;
    error_log  /var/log/nginx/${DOMAIN}.error.log;

    # Hide dotfiles
    location ~ /\.(?!well-known).* { deny all; }
${wp_block:-$(printf '\n    location / {\n        try_files $uri $uri/ =404;\n    }')}
${php_block}
}
EOF

  # Enable the site.
  vhost_enable
  info "Site enabled."
}

# ----------------------------------------------------------------------------
# WordPress
# ----------------------------------------------------------------------------
rand_pw() { openssl rand -base64 24 | tr -d '/+=' | cut -c1-24; }

install_wordpress() {
  # WordPress-only dependencies, installed here (not in install_php) and only if
  # not already present: PHP MySQL driver + common extensions, DB client, curl,
  # tar. ensure_dep/ensure_pkg no-op when the package is already installed.
  ensure_dep php-mysql
  ensure_dep php-extras
  ensure_dep curl
  ensure_dep tar
  ensure_dep mysql-client
  # A running DB server is required to create the WordPress database.
  ensure_database_server
  # If FPM is running, restart it so the just-installed MySQL driver/extensions
  # are actually loaded before WordPress tries to use them.
  [[ -n "${PHP_FPM_SERVICE:-}" ]] && svc_restart "$PHP_FPM_SERVICE" 2>/dev/null || true
  command -v curl >/dev/null 2>&1 || die "curl is required for WordPress download."
  command -v mysql >/dev/null 2>&1 || command -v mariadb >/dev/null 2>&1 || \
    die "MySQL/MariaDB client not found; install a DB server first."
  # Prefer 'mysql', fall back to 'mariadb'.
  local MYSQL_CLI="mysql"; command -v mysql >/dev/null 2>&1 || MYSQL_CLI="mariadb"

  # Only skip if WordPress is ALREADY installed here — we never touch an
  # existing WP install. Otherwise we proceed even into a non-empty doc root,
  # but copy non-destructively below (never overwriting existing files), so you
  # can come back and add WordPress to a site that's already provisioned.
  if [[ -f "$ROOT_DIR/wp-load.php" ]] || [[ -f "$ROOT_DIR/wp-config.php" ]]; then
    warn "WordPress already present in $ROOT_DIR; leaving it untouched."
    return 0
  fi
  [[ "$EXISTING_DOCROOT" == "true" ]] && \
    info "Adding WordPress into existing doc root (existing files preserved)."

  local db_name db_user db_pass wp_admin wp_admin_pass creds_file
  db_name="wp_$(echo "${SITE_USER}" | tr -cd 'a-z0-9' | cut -c1-24)"
  db_user="$db_name"
  db_pass="$(rand_pw)"
  wp_admin="admin"
  wp_admin_pass="$(rand_pw)"

  info "Downloading WordPress..."
  local tmp; tmp="$(mktemp -d)"
  curl -fsSL https://wordpress.org/latest.tar.gz -o "$tmp/wp.tar.gz"
  tar -xzf "$tmp/wp.tar.gz" -C "$tmp"
  # Capture the WordPress top-level entries so that, in an existing doc root, we
  # can fix ownership/permissions on *just the WP files* and leave your other
  # files untouched.
  local wp_items=(); mapfile -t wp_items < <(cd "$tmp/wordpress" && ls -A)
  # -n = never overwrite: any file you already have in the doc root wins, so
  # this is safe to run into an existing site.
  cp -a -n "$tmp/wordpress/." "$ROOT_DIR/"
  rm -rf "$tmp"

  info "Creating database '$db_name' and user '$db_user'..."
  "$MYSQL_CLI" <<SQL
CREATE DATABASE IF NOT EXISTS \`${db_name}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${db_user}'@'localhost' IDENTIFIED BY '${db_pass}';
GRANT ALL PRIVILEGES ON \`${db_name}\`.* TO '${db_user}'@'localhost';
FLUSH PRIVILEGES;
SQL

  info "Generating wp-config.php..."
  local salts
  salts="$(curl -fsSL https://api.wordpress.org/secret-key/1.1/salt/ || true)"
  local wp_config="$ROOT_DIR/wp-config.php"
  cp "$ROOT_DIR/wp-config-sample.php" "$wp_config"
  sed -i \
    -e "s/database_name_here/${db_name}/" \
    -e "s/username_here/${db_user}/" \
    -e "s/password_here/${db_pass}/" \
    "$wp_config"
  if [[ -n "$salts" ]]; then
    # Replace the default salt block with fetched unique keys.
    sed -i "/AUTH_KEY/d;/SECURE_AUTH_KEY/d;/LOGGED_IN_KEY/d;/NONCE_KEY/d;/AUTH_SALT/d;/SECURE_AUTH_SALT/d;/LOGGED_IN_SALT/d;/NONCE_SALT/d" "$wp_config"
    printf '%s\n' "$salts" >> "$wp_config"
  fi

  # Apply ownership/permissions to the WordPress files. In a fresh doc root we
  # cover the whole tree; in an existing one we touch ONLY the WP entries we
  # just added, so your pre-existing files keep their owners and modes.
  local -a targets
  if [[ "$EXISTING_DOCROOT" == "true" ]]; then
    targets=()
    local item
    for item in "${wp_items[@]}"; do
      [[ -e "$ROOT_DIR/$item" ]] && targets+=("$ROOT_DIR/$item")
    done
  else
    targets=("$ROOT_DIR")
  fi
  if [[ ${#targets[@]} -gt 0 ]]; then
    chown -R "$SITE_USER:$WEB_GROUP" "${targets[@]}"
    find "${targets[@]}" -type d -exec chmod 2750 {} +
    find "${targets[@]}" -type f -exec chmod 640  {} +
  fi
  # wp-config holds DB credentials: owner-only, and owned by the FPM user so it
  # can still read it (owner bit, independent of any ACL mask).
  chown "$SITE_USER:$WEB_GROUP" "$wp_config"
  chmod 600 "$wp_config"

  # Save credentials securely.
  creds_file="/root/${DOMAIN}.credentials.txt"
  cat > "$creds_file" <<EOF
WordPress / site credentials for ${DOMAIN}
Generated: $(_ts)

Document root : ${ROOT_DIR}
System user   : ${SITE_USER}

Database name : ${db_name}
Database user : ${db_user}
Database pass : ${db_pass}

WP admin user : ${wp_admin}
WP admin pass : ${wp_admin_pass}
  (Set this on first login via the WordPress installer at http://${DOMAIN}/)
EOF
  chmod 600 "$creds_file"
  info "Credentials saved to $creds_file (root-only)."

  # If we reused a pre-existing vhost, we did not (and must not) rewrite it —
  # it may carry SSL/manual config. So it likely lacks the WordPress permalink
  # rules and the PHP handler. Tell the user exactly what to add.
  if [[ "$VHOST_EXISTED" == "true" ]]; then
    warn "Existing vhost was left unchanged, so WordPress rewrite/PHP rules were NOT added."
    warn "For pretty permalinks + PHP, add this inside the server { } block of"
    warn "  $(vhost_find)  then run: nginx -t && (reload nginx)"
    cat <<EOF | tee -a "$LOG_FILE"
------------------------------------------------------------------
    location / {
        try_files \$uri \$uri/ /index.php?\$args;
    }
    location ~ \.php\$ {
        try_files \$uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)\$;
        fastcgi_pass unix:${PHP_SOCK};
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
        fastcgi_param PATH_INFO \$fastcgi_path_info;
    }
    location = /xmlrpc.php { deny all; }
    location ~* /(?:\.htaccess|wp-config\.php)\$ { deny all; }
------------------------------------------------------------------
EOF
  fi
}

# ----------------------------------------------------------------------------
# Manage an existing vhost
# ----------------------------------------------------------------------------
list_vhosts() {
  if [[ "$NGINX_CONF_MODE" == "confd" ]]; then
    # Strip .conf / .conf.disabled and skip the shipped default.
    { ls -1 "$NGINX_CONFD"/*.conf "$NGINX_CONFD"/*.conf.disabled 2>/dev/null || true; } \
      | xargs -r -n1 basename \
      | sed -e 's/\.conf\.disabled$//' -e 's/\.conf$//' \
      | sort -u | grep -vx 'default' || true
  else
    ls -1 "$NGINX_SITES_AVAILABLE" 2>/dev/null | grep -vx 'default' || true
  fi
}

select_vhost() {
  # Sets DOMAIN. If given via --domain, just validate it exists.
  local sites=(); mapfile -t sites < <(list_vhosts)
  [[ ${#sites[@]} -gt 0 ]] || die "No vhosts found to manage."

  if [[ -n "$DOMAIN" ]]; then
    DOMAIN="$DOMAIN"
    [[ -n "$(vhost_find)" ]] || die "No vhost named '$DOMAIN' found."
    return 0
  fi
  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    die "--manage --non-interactive requires --domain."
  fi

  echo "Existing vhosts:"
  local i=1 s
  for s in "${sites[@]}"; do printf '  %d) %s\n' "$i" "$s"; ((i++)); done
  local choice=""
  read -r -p "Select a vhost by number, or type a domain: " choice
  if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#sites[@]} )); then
    DOMAIN="${sites[$((choice-1))]}"
  else
    DOMAIN="$choice"
  fi
  [[ -n "$DOMAIN" && -n "$(vhost_find)" ]] || die "Invalid selection: '$choice'."
}

vhost_docroot() {
  # Extract the first `root` directive value from a server block.
  awk '/^[[:space:]]*root[[:space:]]/{gsub(/;/,"",$2); print $2; exit}' "$1"
}

backup_docroot() {
  # Copy the current doc root contents somewhere else before we replace them.
  local default dest
  default="/var/backups/${DOMAIN}-$(date +%Y%m%d-%H%M%S)"
  dest=""
  prompt dest "Backup destination directory" "$default"
  [[ -n "$dest" ]] || die "No backup destination given."
  mkdir -p "$dest"
  info "Backing up ${ROOT_DIR} -> ${dest} ..."
  # /. copies contents including dotfiles; -a preserves owners/modes/times.
  if [[ -n "$(ls -A "$ROOT_DIR" 2>/dev/null)" ]]; then
    cp -a "$ROOT_DIR/." "$dest/"
  fi
  BACKUP_DIR="$dest"
  info "Backup complete: ${BACKUP_DIR}"
}

wipe_docroot() {
  info "Clearing existing contents of ${ROOT_DIR} ..."
  # Remove everything inside the doc root but keep the directory itself.
  find "$ROOT_DIR" -mindepth 1 -delete
}

# --- Small status helpers used by the manage menu -------------------------
site_enabled()  { vhost_is_enabled; }
site_has_ssl()  { local c; c="$(vhost_find)"; [[ -n "$c" ]] && grep -Eq 'ssl_certificate|listen[^;]*443' "$c" 2>/dev/null; }

# --- Manage actions -------------------------------------------------------
action_show_info() {
  local conf; conf="$(vhost_find)"
  echo
  echo "  Site info: ${DOMAIN}"
  echo "  --------------------------------------------"
  echo "  Config     : ${conf}"
  echo "  Doc root   : ${ROOT_DIR}"
  echo "  Site user  : ${SITE_USER}"
  echo "  Enabled    : $(site_enabled && echo yes || echo no)"
  echo "  SSL/TLS    : $(site_has_ssl && echo yes || echo no)"
  if [[ -d "$ROOT_DIR" ]]; then
    echo "  Disk usage : $(du -sh "$ROOT_DIR" 2>/dev/null | cut -f1)"
    echo "  Files      : $(find "$ROOT_DIR" -type f 2>/dev/null | wc -l)"
    [[ -f "$ROOT_DIR/wp-load.php" ]] && echo "  WordPress  : detected"
  fi
  echo
}

action_install_wordpress() {
  INSTALL_PHP="y"; INSTALL_WP="y"
  warn "WARNING: installing WordPress here will REPLACE the entire contents of:"
  warn "         ${ROOT_DIR}"
  warn "         Everything currently in that directory will be removed."

  local do_backup=""
  prompt_yn do_backup "Back up the current contents somewhere else on this system first?"
  [[ "$do_backup" == "y" ]] && backup_docroot

  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    [[ "$FORCE" == "true" ]] || \
      die "Refusing to replace ${ROOT_DIR} non-interactively without --force."
  else
    local confirm=""
    prompt_yn confirm "Proceed and REPLACE ${ROOT_DIR} with a fresh WordPress install?"
    [[ "$confirm" == "y" ]] || { warn "Aborted; nothing changed."; return 0; }
  fi

  create_site_user
  install_php
  create_php_pool
  wipe_docroot
  EXISTING_DOCROOT="false"
  install_wordpress
  reload_nginx
  info "WordPress installed for ${DOMAIN}. Creds: /root/${DOMAIN}.credentials.txt"
  [[ -n "$BACKUP_DIR" ]] && info "Previous contents backed up to: ${BACKUP_DIR}"
}

action_enable_ssl() {
  INSTALL_SSL="y"
  install_ssl
}

action_backup_only() {
  backup_docroot
  info "Backup finished: ${BACKUP_DIR}"
}

action_toggle_listing() {
  local conf; conf="$(vhost_find)"
  [[ -n "$conf" ]] || { warn "No config file found for ${DOMAIN}."; return 0; }
  if grep -q 'autoindex on;' "$conf"; then
    sed -i 's/autoindex on;/autoindex off;/' "$conf"
    info "Directory listing turned OFF."
  elif grep -q 'autoindex off;' "$conf"; then
    sed -i 's/autoindex off;/autoindex on;/' "$conf"
    info "Directory listing turned ON."
  else
    warn "No 'autoindex' directive found in $conf; not changing anything."
    return 0
  fi
  reload_nginx
}

action_repair_perms() {
  # Re-apply the ownership/ACL baseline so both Nginx (WEB_USER) and the
  # PHP-FPM pool user (SITE_USER) can read/traverse the site. Fixes the
  # classic PHP-FPM "File not found" caused by missing FPM-user access.
  create_site_user
  [[ -d "$ROOT_DIR" ]] || die "Doc root $ROOT_DIR does not exist."
  info "Repairing ownership, permissions and ACLs on ${ROOT_DIR}..."
  chown -R "$SITE_USER:$WEB_GROUP" "$ROOT_DIR"
  find "$ROOT_DIR" -type d -exec chmod 2750 {} +
  find "$ROOT_DIR" -type f -exec chmod 640  {} +
  apply_acls "$ROOT_DIR"
  # Keep wp-config locked down if present.
  [[ -f "$ROOT_DIR/wp-config.php" ]] && chmod 600 "$ROOT_DIR/wp-config.php"
  info "Permissions repaired."
}

action_disable_site() {
  if ! site_enabled; then warn "Site is already disabled."; return 0; fi
  vhost_disable
  reload_nginx
  info "Site ${DOMAIN} disabled (config kept, not served)."
}

action_enable_site() {
  if site_enabled; then warn "Site is already enabled."; return 0; fi
  vhost_enable
  reload_nginx
  info "Site ${DOMAIN} enabled."
}

action_view_error_log() {
  local elog="/var/log/nginx/${DOMAIN}.error.log"
  if [[ -f "$elog" ]]; then
    echo "  --- last 30 lines of ${elog} ---"
    tail -n 30 "$elog"
    echo "  --------------------------------"
  else
    warn "No error log found at $elog yet."
  fi
}

action_delete_site() {
  warn "WARNING: this will DELETE the vhost for ${DOMAIN}."
  local del_root="" do_backup="" confirm=""
  prompt_yn del_root "Also delete the document root (${ROOT_DIR})?"
  if [[ "$del_root" == "y" ]]; then
    prompt_yn do_backup "Back up the document root before deleting it?"
    [[ "$do_backup" == "y" ]] && backup_docroot
  fi
  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    [[ "$FORCE" == "true" ]] || die "Refusing to delete non-interactively without --force."
  else
    prompt_yn confirm "Type y to permanently remove the ${DOMAIN} vhost"
    [[ "$confirm" == "y" ]] || { warn "Aborted; nothing deleted."; return 0; }
  fi

  # Remove enabled + available copies, in either layout.
  if [[ "$NGINX_CONF_MODE" == "confd" ]]; then
    rm -f "${NGINX_CONFD}/${DOMAIN}.conf" "${NGINX_CONFD}/${DOMAIN}.conf.disabled"
  else
    rm -f "${NGINX_SITES_ENABLED}/${DOMAIN}" "${NGINX_SITES_AVAILABLE}/${DOMAIN}"
  fi
  # Remove the dedicated PHP-FPM pool(s) for this domain, if any.
  local pool
  for pool in /etc/php/*/fpm/pool.d/"${DOMAIN}".conf \
              /etc/php-fpm.d/"${DOMAIN}".conf \
              /etc/php/php-fpm.d/"${DOMAIN}".conf \
              /etc/php*/fpm/php-fpm.d/"${DOMAIN}".conf \
              /etc/php*/php-fpm.d/"${DOMAIN}".conf; do
    [[ -f "$pool" ]] && { rm -f "$pool"; info "Removed PHP-FPM pool: $pool"; }
  done
  if [[ "$del_root" == "y" && -d "$ROOT_DIR" ]]; then
    rm -rf "$ROOT_DIR"
    info "Deleted document root ${ROOT_DIR}."
  fi
  svc_reload nginx 2>/dev/null || true
  # Reload any php-fpm services that had pools removed.
  if [[ "$INIT_SYSTEM" == "systemd" ]]; then
    systemctl list-units --type=service 2>/dev/null | grep -oE 'php[0-9.]*-fpm' | sort -u | \
      while read -r svc; do systemctl reload "$svc" 2>/dev/null || true; done
  else
    [[ -n "${PHP_FPM_SERVICE:-}" ]] && rc-service "$PHP_FPM_SERVICE" reload 2>/dev/null || true
  fi
  info "Vhost ${DOMAIN} deleted."
  [[ -n "$BACKUP_DIR" ]] && info "Backup kept at: ${BACKUP_DIR}"
  DOMAIN=""   # signal caller that this site is gone
}

# --- The per-site manage menu loop ----------------------------------------
manage_menu() {
  local choice=""
  while true; do
    echo
    echo "=================================="
    echo " Manage: ${DOMAIN}"
    echo "   root: ${ROOT_DIR}"
    echo "   $(site_enabled && echo enabled || echo disabled) | SSL: $(site_has_ssl && echo on || echo off)"
    echo "=================================="
    echo "  1) Show site info"
    echo "  2) Install WordPress (replaces doc root)"
    echo "  3) Enable/obtain SSL (certbot)"
    echo "  4) Back up the document root"
    echo "  5) Toggle directory listing"
    echo "  6) Repair permissions/ACLs"
    echo "  7) $(site_enabled && echo 'Disable' || echo 'Enable') this site"
    echo "  8) View recent error log"
    echo "  9) Delete this vhost"
    echo "  b) Back to main menu"
    echo "  q) Quit"
    echo "----------------------------------"
    read -r -p "Choose an option: " choice
    case "$choice" in
      1) action_show_info ;;
      2) action_install_wordpress ;;
      3) action_enable_ssl ;;
      4) action_backup_only ;;
      5) action_toggle_listing ;;
      6) action_repair_perms ;;
      7) if site_enabled; then action_disable_site; else action_enable_site; fi ;;
      8) action_view_error_log ;;
      9) action_delete_site; [[ -z "$DOMAIN" ]] && return 0 ;;
      b|B) return 0 ;;
      q|Q) info "Bye."; exit 0 ;;
      *) warn "Invalid choice: '$choice'." ;;
    esac
  done
}

manage_existing_vhost() {
  info "=== Manage existing vhost ==="
  select_vhost
  validate_domain "$DOMAIN"

  local conf; conf="$(vhost_find)"
  VHOST_EXISTED="true"
  ROOT_DIR="$(vhost_docroot "$conf")"
  [[ -n "$ROOT_DIR" ]] || die "Could not read document root from $conf."
  [[ -z "$SITE_USER" ]] && SITE_USER="$(derive_user)"
  info "Managing '${DOMAIN}'  (doc root: ${ROOT_DIR}, user: ${SITE_USER})"

  # Non-interactive shortcut: --manage --wordpress runs the WP action directly.
  if [[ "$NON_INTERACTIVE" == "true" ]]; then
    [[ "$INSTALL_WP" == "y" ]] && action_install_wordpress
    return 0
  fi

  manage_menu
}

# ----------------------------------------------------------------------------
# SSL / Let's Encrypt (certbot)
# ----------------------------------------------------------------------------
ensure_certbot() {
  if ! command -v certbot >/dev/null 2>&1; then
    info "certbot not found; installing certbot + nginx plugin..."
    ensure_dep certbot
  fi
  command -v certbot >/dev/null 2>&1 || die "Failed to install certbot."
}

install_ssl() {
  ensure_certbot

  # certbot needs a validated contact email for the ACME account.
  if [[ -z "$SSL_EMAIL" ]]; then
    if [[ "$NON_INTERACTIVE" == "true" ]]; then
      die "SSL requested but no --email provided (required in non-interactive mode)."
    fi
    prompt SSL_EMAIL "Email for Let's Encrypt notices"
  fi

  info "Requesting Let's Encrypt certificate for ${DOMAIN} (and www)..."
  # --nginx installs the cert and rewrites the server block for TLS + redirect.
  # This is the step that needs public DNS pointing at this host on port 80/443.
  if certbot --nginx \
       --non-interactive --agree-tos \
       --email "$SSL_EMAIL" \
       --redirect \
       -d "$DOMAIN" -d "www.$DOMAIN" >>"$LOG_FILE" 2>&1; then
    info "TLS certificate installed; HTTP now redirects to HTTPS."
    # certbot installs a systemd timer for renewal; verify a dry run works.
    if certbot renew --dry-run >>"$LOG_FILE" 2>&1; then
      info "Auto-renewal dry run succeeded."
    else
      warn "Auto-renewal dry run failed; check 'certbot renew --dry-run' manually."
    fi
  else
    warn "certbot failed (DNS not pointed here yet? port 80/443 blocked?)."
    warn "Site is live over HTTP. Re-run later: certbot --nginx -d ${DOMAIN} -d www.${DOMAIN}"
  fi
}

# ----------------------------------------------------------------------------
# Finalize
# ----------------------------------------------------------------------------
reload_nginx() {
  info "Testing Nginx configuration..."
  if nginx -t >>"$LOG_FILE" 2>&1; then
    svc_reload nginx
    info "Nginx reloaded successfully."
  else
    error "Nginx config test FAILED. Rolling back site enablement."
    vhost_disable
    die "See $LOG_FILE for the nginx -t output."
  fi
}

# ----------------------------------------------------------------------------
# Top-level menu (shown when the script is run with no arguments)
# ----------------------------------------------------------------------------
main_menu() {
  echo
  echo "=================================="
  echo "        Nginx Site Manager"
  echo "     (${OS_PRETTY})"
  echo "=================================="
  echo "  1) Create a new site"
  echo "  2) Manage an existing site"
  echo "  q) Quit"
  echo "----------------------------------"
  local choice=""
  read -r -p "Choose an option [1/2/q]: " choice
  case "$choice" in
    1) MENU_CHOICE="create" ;;
    2) MENU_CHOICE="manage" ;;
    q|Q|"") MENU_CHOICE="quit" ;;
    *) warn "Invalid choice: '$choice' (pick 1, 2 or q)."; MENU_CHOICE="again" ;;
  esac
}

# Reset per-run state so a second pass through the menu starts clean.
reset_state() {
  DOMAIN=""; ROOT_DIR=""; SITE_USER=""
  INSTALL_PHP=""; INSTALL_WP=""; INSTALL_SSL=""; SSL_EMAIL=""
  ENABLE_LISTING="n"; MANAGE_MODE="false"
  EXISTING_DOCROOT="false"; VHOST_EXISTED="false"; BACKUP_DIR=""
  PHP_VERSION=""; PHP_SOCK=""; PHP_FPM_POOL_DIR=""; PHP_FPM_SERVICE=""
}

# ----------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------
create_new_site() {
  prompt DOMAIN "Enter the domain name (e.g. example.com)"
  validate_domain "$DOMAIN"

  prompt ROOT_DIR "Document root" "/var/www/${DOMAIN}/public_html"
  [[ -z "$SITE_USER" ]] && SITE_USER="$(derive_user)"

  prompt_yn INSTALL_PHP "Install/enable PHP for this site?"
  prompt_yn INSTALL_WP  "Install WordPress on this domain?"
  prompt_yn INSTALL_SSL "Obtain a Let's Encrypt SSL certificate (certbot)?"
  [[ "$INSTALL_WP" == "y" ]] && INSTALL_PHP="y"

  create_site_user
  setup_docroot

  if [[ "$INSTALL_PHP" == "y" ]]; then
    install_php
    create_php_pool
  fi

  write_nginx_conf

  [[ "$INSTALL_WP" == "y" ]] && install_wordpress

  reload_nginx

  # SSL runs last: certbot --nginx needs the HTTP server block already live,
  # and it reloads Nginx itself after editing the config.
  [[ "$INSTALL_SSL" == "y" ]] && install_ssl

  info "=== Done. Site ${DOMAIN} is provisioned. ==="
  echo
  echo "Summary:"
  echo "  OS        : ${OS_PRETTY} (${OS_FAMILY})"
  echo "  Domain    : ${DOMAIN}"
  echo "  Doc root  : ${ROOT_DIR}$([[ "$EXISTING_DOCROOT" == "true" ]] && echo ' (existing, preserved)')"
  echo "  User      : ${SITE_USER}"
  echo "  PHP       : ${INSTALL_PHP} ${PHP_VERSION:+($PHP_VERSION)}"
  echo "  WordPress : ${INSTALL_WP}"
  echo "  SSL/TLS   : ${INSTALL_SSL:-n}"
  local scheme="http"; [[ "$INSTALL_SSL" == "y" ]] && scheme="https"
  echo "  URL       : ${scheme}://${DOMAIN}/"
  [[ "$INSTALL_WP" == "y" ]] && echo "  Creds     : /root/${DOMAIN}.credentials.txt"
}

# ----------------------------------------------------------------------------
main() {
  local argc=$#
  parse_args "$@"
  require_root
  touch "$LOG_FILE"; chmod 640 "$LOG_FILE"
  info "=== Starting ($SCRIPT_NAME) ==="

  detect_os
  check_dependencies

  # No arguments? Drive everything from the interactive menu, looping until the
  # user quits. Passing flags (e.g. --manage) skips the menu entirely.
  if [[ "$argc" -eq 0 ]]; then
    while true; do
      reset_state
      main_menu
      case "$MENU_CHOICE" in
        create) create_new_site ;;
        manage) manage_existing_vhost ;;
        quit)   info "Bye."; return 0 ;;
        again)  : ;;   # invalid input; redraw the menu
      esac
    done
  fi

  # Flag-driven single run.
  if [[ "$MANAGE_MODE" == "true" ]]; then
    manage_existing_vhost
    info "=== Done (manage mode). ==="
    return 0
  fi
  create_new_site
}

main "$@"
