#!/usr/bin/env bash
#
# paas — minimal deployment platform CLI (dotnews-paas).
# The ONLY writer of server state: app registry, Caddy/Varnish config,
# PM2 processes, releases. The web panel shells out to this.
#
# Layout:
#   /srv/paas/apps.d/<app>.conf   registry (KEY=VALUE)
#   /srv/paas/caddy/app-*.caddy   generated Caddy site blocks (imported)
#   /srv/paas/vcl/paas-*.vcl      generated Varnish backends/routing (included)
#   /srv/apps/<app>/releases/     one dir per deploy, current -> active release
#   /srv/apps/<app>/shared/.env   app environment (sourced by run.sh / read by Laravel)
#   /srv/apps/<app>/static/       accumulated public/ assets, served by Caddy (node apps)
#   /srv/paas/php/<ver>/*.conf    generated PHP-FPM pools (included), RUNTIME=laravel apps
#   /srv/paas/db-admin.cnf        MariaDB admin credentials for 'paas db' (WITH_LARAVEL servers)
#
# Runtimes (registry RUNTIME=, default node):
#   node     PM2 process on 127.0.0.1:PORT (Nuxt/Nitro default)
#   laravel  PHP-FPM pool + Caddy php_fastcgi (public site) and a loopback
#            http://127.0.0.1:PORT listener (health/monitoring), no PM2
#
# Deployment records go to sqlite (/srv/paas/paas.db, table deployments).
# Env contract with the panel: DEPLOY_ID (row to update), PAAS_SOURCE, PAAS_COMMIT.

set -euo pipefail

PAAS_ROOT="${PAAS_ROOT:-/srv/paas}"
APPS_ROOT="${APPS_ROOT:-/srv/apps}"
# platform-wide settings (SYSTEM_DOMAIN_BASE for auto *.base wildcard domains)
# shellcheck disable=SC1091
[[ -f "$PAAS_ROOT/paas.conf" ]] && source "$PAAS_ROOT/paas.conf"
REG_DIR="$PAAS_ROOT/apps.d"
CADDY_DIR="$PAAS_ROOT/caddy"
VCL_DIR="$PAAS_ROOT/vcl"
LOG_DIR="$PAAS_ROOT/logs"
DB="${PAAS_DB:-$PAAS_ROOT/paas.db}"
VARNISH_ADDR="${VARNISH_ADDR:-127.0.0.1:6081}"
# prefer the xcaddy-built binary (includes maxmind_geolocation) for validation
CADDY_BIN="/usr/local/bin/caddy"; [[ -x "$CADDY_BIN" ]] || CADDY_BIN="caddy"
KEEP_RELEASES="${KEEP_RELEASES:-5}"
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-45}"
# laravel runtime + database provisioning (only present on WITH_LARAVEL servers)
PHP_DIR="$PAAS_ROOT/php"
DB_ADMIN_CNF="$PAAS_ROOT/db-admin.cnf"
DB_BACKUP_DIR="$PAAS_ROOT/backups/db"
DB_BACKUP_KEEP="${DB_BACKUP_KEEP:-7}"
PHP_DEFAULT="${PHP_DEFAULT:-8.3}"
DEFAULT_QUEUE_CMD="php artisan queue:work --sleep=3 --tries=3 --max-time=3600"

log()  { printf '[paas] %s\n' "$*"; }
die()  { printf '[paas] ERROR: %s\n' "$*" >&2; exit 1; }

usage() {
    cat <<'EOF'
paas — minimal deployment platform

Usage:
  paas create <app> --domains d1[,d2] [--cache] [--noindex]
              [--start "node server/index.mjs"] [--health /api/_health]
              [--pass-paths "<regex>"] [--warm-paths "/,/news"] [--port N]
              [--warm-sitemap /news-sitemap.xml]  # warm its <loc> URLs after deploy/rollback
              [--runtime node|laravel] [--php 8.3]  # laravel: PHP-FPM, no Varnish, health /up
  paas deploy <app> <built-output-dir>     # node: a Nuxt .output; laravel: the built checkout
  paas rollback <app>
  paas restart <app>                       # node: pm2 restart; laravel: re-cache config + fpm reload
  paas php <app> --set <version>           # select PHP version (/srv/paas/php/<ver>), laravel apps
  paas db create|show|drop|backup|shell <app>   # MariaDB per app (credentials -> shared/.env)
  paas db backup --all                     # all apps with a database (nightly timer)
  paas domains <app> --add <d> | --remove <d>
  paas domain-rules <app> --set-json <file|-> | --clear | --show
                                            # restrict a domain to a path allowlist and/or noindex it
  paas ban <app>                           # purge this app's Varnish cache
  paas warm <app>                          # pre-render WARM_PATHS (+ WARM_SITEMAP locs) into the cache
  paas ssl <app>                           # retry cert issuance + report per-domain status
  paas node <app> --set <version|system>   # select Node.js version (/opt/node/vNN)
  paas set <app> [--cache on|off] [--noindex on|off] [--health <path>]
                 [--warm-paths <p,p>] [--warm-sitemap <path>] [--geo-exempt-google on|off]  # registry flags + regen
  paas enable|disable <app>                # public 503 toggle (app keeps running)
  paas protect <app> --user U --password P # basic-auth in front of all domains (--off removes)
  paas list
  paas info <app>
  paas logs <app>                          # tail pm2 logs
  paas remove <app> [--purge] [--yes]
  paas regen                               # regenerate + reload all configs
EOF
    exit "${1:-0}"
}

# ---------------------------------------------------------------- helpers ----

require_app_name() {
    [[ "$1" =~ ^[a-z][a-z0-9-]{1,30}$ ]] || die "invalid app name '$1' (use ^[a-z][a-z0-9-]{1,30}$)"
}

conf_path() { printf '%s/%s.conf' "$REG_DIR" "$1"; }

load_app() {
    APP="$1"
    require_app_name "$APP"
    local conf; conf="$(conf_path "$APP")"
    [[ -f "$conf" ]] || die "unknown app '$APP' (no $conf)"
    # registry files are machine-written KEY=VALUE, safe to source
    # shellcheck disable=SC1090
    source "$conf"
    APP_DIR="$APPS_ROOT/$APP"
}

sq() { # escape a string for use inside single-quoted SQL
    printf '%s' "${1//\'/\'\'}"
}

db_exec() {
    command -v sqlite3 >/dev/null || return 0
    [[ -f "$DB" ]] || return 0
    sqlite3 "$DB" "$1" 2>/dev/null || log "warning: sqlite write failed (non-fatal)"
}

db_query() {
    sqlite3 "$DB" "$1" 2>/dev/null || true
}

next_free_port() {
    local used p=3001
    used="$(grep -hs '^PORT=' "$REG_DIR"/*.conf 2>/dev/null | cut -d= -f2 || true)"
    while grep -qx "$p" <<<"$used" || [[ "$p" -eq 3900 ]]; do p=$((p+1)); done
    printf '%s' "$p"
}

primary_domain() { # explicit MAIN_DOMAIN wins; else first custom; else system
    if [[ -n "${MAIN_DOMAIN:-}" ]]; then printf '%s' "$MAIN_DOMAIN"
    elif [[ -n "${DOMAINS:-}" ]]; then cut -d, -f1 <<<"$DOMAINS"
    else printf '%s' "${SYSTEM_DOMAIN:-}"; fi
}

all_domains() { # custom domains + the always-available system domain
    local d="${DOMAINS:-}"
    [[ -n "${SYSTEM_DOMAIN:-}" ]] && d="${d:+$d,}$SYSTEM_DOMAIN"
    printf '%s' "$d"
}

reset_app_vars() { # prevent key leakage between registry files in loops
    APP=''; PORT=''; DOMAINS=''; CACHE=off; NOINDEX=off; START_CMD=''
    HEALTH_PATH=/; PASS_PATHS=''; WARM_PATHS=/; WARM_SITEMAP=''; SYSTEM_DOMAIN=''; REDIRECT_DOMAINS=''
    MAIN_DOMAIN=''; NODE_VERSION=''; ENABLED=on; BASIC_AUTH_USER=''; BASIC_AUTH_HASH=''
    RUNTIME=node; PHP_VERSION=''; MIGRATE=on; QUEUE_WORKER=off; QUEUE_CMD=''; SCHEDULER=off
    FPM_MAX_CHILDREN=8; GEO_EXEMPT_GOOGLE=on
}

# ------------------------------------------------------- runtime helpers ----

is_laravel() { [[ "${RUNTIME:-node}" == "laravel" ]]; }

server_runtimes() { printf '%s' "${RUNTIMES:-node}"; } # from paas.conf (bootstrap WITH_LARAVEL=1)

require_runtime_support() { # $1 = runtime
    [[ "$1" == "node" ]] && return 0
    tr ',' '\n' <<<"$(server_runtimes)" | grep -qx "$1" \
        || die "runtime '$1' is not enabled on this server (RUNTIMES=$(server_runtimes) in $PAAS_ROOT/paas.conf — bootstrap with WITH_LARAVEL=1)"
}

php_bin() { # the app's PHP CLI (exact version, never the update-alternatives default)
    local v="${PHP_VERSION:-$PHP_DEFAULT}"
    [[ -x "/usr/bin/php$v" ]] || die "PHP $v is not installed (/usr/bin/php$v missing)"
    printf '/usr/bin/php%s' "$v"
}

php_path_prefix() { printf '%s/bin/%s' "$PHP_DIR" "${PHP_VERSION:-$PHP_DEFAULT}"; }

artisan() { # $1 = release/app dir, rest = artisan args — runs as the deploy user
    local dir="$1"; shift
    local php; php="$(php_bin)"
    ( cd "$dir" && PATH="$(php_path_prefix):$PATH" "$php" artisan "$@" --no-interaction --no-ansi )
}

fpm_socket() { printf '/run/php/paas-%s.sock' "$1"; }

fpm_unit() { printf 'php%s-fpm' "${PHP_VERSION:-$PHP_DEFAULT}"; }

fpm_state() { # online | stopped — socket present and the fpm master for this version running
    if [[ -S "$(fpm_socket "$APP")" ]] && systemctl is-active --quiet "$(fpm_unit)"; then
        printf 'online'
    else
        printf 'stopped'
    fi
}

# .env editing (KEY=VALUE lines, comments preserved, 0600 kept). Values are
# written verbatim: callers pass only [A-Za-z0-9_./:@+=-] content.
env_get_key() { # $1 file, $2 KEY
    [[ -f "$1" ]] || return 0
    sed -n "s/^$2=//p" "$1" | tail -1 | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/"
}

env_set_key() { # $1 file, $2 KEY, $3 VALUE  — replace in place or append
    local file="$1" key="$2" val="$3" tmp
    [[ -f "$file" ]] || { touch "$file"; chmod 600 "$file"; }
    tmp="$(mktemp "$file.XXXXXX")"
    awk -v k="$key" -v v="$val" '
        BEGIN { done = 0 }
        index($0, k "=") == 1 { if (!done) { print k "=" v; done = 1 } ; next }
        { print }
        END { if (!done) print k "=" v }
    ' "$file" >"$tmp"
    chmod 600 "$tmp"
    mv "$tmp" "$file"
}

GEOIP_DB="${GEOIP_DB:-$PAAS_ROOT/geoip/country.mmdb}"
# Google's official crawler/AdsBot IP ranges (special-crawlers.json +
# common-crawlers.json, merged) — refreshed daily by paas-google-crawlers-refresh.
GOOGLE_CRAWLER_IPS="${GOOGLE_CRAWLER_IPS:-$PAAS_ROOT/geoip/google-crawlers.cidr}"

# Emit Caddy geo matchers + 403 responders for an app's geo.json (if any).
# Modes: "block" = 403 for listed countries; "allow-only" = 403 for everyone
# not listed (UNK/private IPs — health checks, warming, snapshots — always pass).
# Google's published crawler/AdsBot IP ranges (GOOGLE_CRAWLER_IPS, if present) are
# exempt too, per-app (GEO_EXEMPT_GOOGLE, default on — geo-blocking a market
# shouldn't also break Google's indexing or ad-verification crawls of it, but
# an app can opt back in to blocking Google's crawlers like anyone else).
emit_geo_directives() { # $1 = geo.json path
    [[ -f "$1" && -f "$GEOIP_DB" ]] || return 0
    python3 - "$1" "$GEOIP_DB" "$GOOGLE_CRAWLER_IPS" "${GEO_EXEMPT_GOOGLE:-on}" <<'PY'
import json, re, sys
path, db, crawler_ips_path, exempt_google = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
try:
    rules = json.load(open(path)).get("rules", [])
except Exception:
    sys.exit(0)
cc = re.compile(r"^[A-Z]{2}$")
cidr_re = re.compile(r"^[0-9a-fA-F:.]+/[0-9]{1,3}$")
crawler_ips = []
if exempt_google != "off":
    try:
        with open(crawler_ips_path) as f:
            crawler_ips = [line.strip() for line in f if cidr_re.match(line.strip())]
    except OSError:
        pass
for i, r in enumerate(rules):
    countries = [c for c in r.get("countries", []) if cc.match(str(c))]
    paths = [p for p in r.get("paths", []) if str(p).startswith("/") and re.match(r"^[A-Za-z0-9_\-./*]+$", str(p))]
    mode = r.get("mode", "block")
    if not countries:
        continue
    print(f"    @paas_geo_{i} {{")
    if paths:
        print(f"        path {' '.join(paths)}")
    if crawler_ips:
        print(f"        not remote_ip {' '.join(crawler_ips)}")
    print("        maxmind_geolocation {")
    print(f"            db_path {db}")
    if mode == "allow-only":
        print(f"            deny_countries {' '.join(countries)} UNK")
    else:
        print(f"            allow_countries {' '.join(countries)}")
    print("        }")
    print("    }")
    # handle blocks run in order of appearance — geo handles are emitted
    # before the static/proxy handles, so a match wins with a 403
    print(f"    handle @paas_geo_{i} {{")
    print('        respond "Not available in your region" 403')
    print("    }")
PY
}

# Emit Caddy 404 blocks for an app's domain-rules.json (if any): a domain
# with a non-empty `paths` allowlist serves ONLY those paths — everything
# else on that host 404s here, before the static/reverse_proxy handles ever
# see it. Domains with no rule, and non-listed paths on other domains, are
# untouched and fall through to the normal chain.
emit_domain_path_blocks() { # $1 = domain-rules.json path
    [[ -f "$1" ]] || return 0
    python3 - "$1" <<'PY'
import json, re, sys
path = sys.argv[1]
try:
    rules = json.load(open(path)).get("rules", [])
except Exception:
    sys.exit(0)
for i, r in enumerate(rules):
    domain = r.get("domain", "")
    paths = [p for p in r.get("paths", []) if str(p).startswith("/") and re.match(r"^[A-Za-z0-9_\-./*]+$", str(p))]
    if not domain or not paths:
        continue
    print(f"    @paas_domrule_{i} {{")
    print(f"        host {domain}")
    print(f"        not path {' '.join(paths)}")
    print("    }")
    print(f"    handle @paas_domrule_{i} {{")
    print("        respond 404")
    print("    }")
PY
}

# Emit per-domain X-Robots-Tag headers for an app's domain-rules.json (if
# any) — independent of the app-wide NOINDEX flag and the system domain's
# forced noindex.
emit_domain_noindex_headers() { # $1 = domain-rules.json path
    [[ -f "$1" ]] || return 0
    python3 - "$1" <<'PY'
import json, sys
path = sys.argv[1]
try:
    rules = json.load(open(path)).get("rules", [])
except Exception:
    sys.exit(0)
for i, r in enumerate(rules):
    domain = r.get("domain", "")
    if not domain or not r.get("noindex"):
        continue
    print(f"    @paas_domrule_noindex_{i} {{")
    print(f"        host {domain}")
    print("    }")
    print(f'    header @paas_domrule_noindex_{i} X-Robots-Tag "noindex, nofollow"')
PY
}

# --------------------------------------------------------- config generation ----

# Caddy lines for a PHP document root. resolve_root_symlink: Caddy resolves
# current/ -> releases/<ts> per request and passes the REAL path as
# SCRIPT_FILENAME, so opcache keys on the release — a symlink flip is live on
# the next request and no php-fpm reload is ever needed for a deploy.
emit_caddy_php_site() { # $1 = docroot, $2 = indent (default 4 spaces)
    local root="$1" i="${2:-    }"
    echo "${i}root * $root"
    echo "${i}php_fastcgi unix/$(fpm_socket "$APP") {"
    echo "${i}    resolve_root_symlink"
    echo "${i}}"
    echo "${i}file_server {"
    echo "${i}    hide .htaccess"
    echo "${i}}"
}

fpm_pool_path() { printf '%s/%s/paas-%s.conf' "$PHP_DIR" "$1" "$2"; } # $1 ver, $2 app

write_fpm_pool() { # stdout; uses the loaded app vars
    local max="${FPM_MAX_CHILDREN:-8}"
    [[ "$max" =~ ^[0-9]+$ ]] || max=8
    cat <<EOF
; GENERATED by paas — do not edit. App: $APP (PHP ${PHP_VERSION:-$PHP_DEFAULT})
[paas-$APP]
user = deploy
group = deploy
listen = $(fpm_socket "$APP")
listen.owner = caddy
listen.group = caddy
listen.mode = 0660

pm = ondemand
pm.max_children = $max
pm.process_idle_timeout = 60s
pm.max_requests = 500
request_terminate_timeout = 120s

env[PATH] = $(php_path_prefix):/usr/local/bin:/usr/bin:/bin

php_admin_value[error_log] = $APPS_ROOT/$APP/shared/storage/logs/php-fpm.log
php_admin_flag[log_errors] = on
php_admin_flag[display_errors] = off
php_admin_flag[expose_php] = off
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 64M
EOF
}

# Regenerate every laravel app's PHP-FPM pool: stage -> php-fpm -t (root, via
# sudoers) -> commit -> reload. A pool that moves to another PHP version is
# removed from the losing master FIRST (it unlinks the socket on reload),
# then the gaining one binds it. Never called from deploy (nothing changes).
regen_fpm() {
    [[ -d "$PHP_DIR" ]] || return 0   # node-only server
    local vers=() v conf
    for v in "$PHP_DIR"/[0-9]*.[0-9]*; do [[ -d "$v" ]] && vers+=("$(basename "$v")"); done
    [[ ${#vers[@]} -gt 0 ]] || return 0

    for v in "${vers[@]}"; do rm -rf "$PHP_DIR/$v/.staged"; mkdir -p "$PHP_DIR/$v/.staged"; done
    for conf in "$REG_DIR"/*.conf; do
        [[ -e "$conf" ]] || continue
        # shellcheck disable=SC1090
        ( reset_app_vars
          source "$conf"
          is_laravel || exit 0
          pv="${PHP_VERSION:-$PHP_DEFAULT}"
          [[ -d "$PHP_DIR/$pv" ]] || die "app $APP wants PHP $pv which is not installed under $PHP_DIR"
          write_fpm_pool >"$PHP_DIR/$pv/.staged/paas-$APP.conf"
        )
    done

    # swap in, remember what changed per version
    local -A changed=() removed=()
    for v in "${vers[@]}"; do
        local d="$PHP_DIR/$v" bak="$PHP_DIR/$v/.bak" f
        rm -rf "$bak"; mkdir -p "$bak"
        find "$d" -maxdepth 1 -name 'paas-*.conf' -exec mv {} "$bak"/ \;
        if compgen -G "$d/.staged/paas-*.conf" >/dev/null; then mv "$d"/.staged/paas-*.conf "$d"/; fi
        rm -rf "$d/.staged"
        if ! diff -rq "$bak" "$d" -x '.bak' -x '.staged' >/dev/null 2>&1; then changed[$v]=1; fi
        for f in "$bak"/paas-*.conf; do
            [[ -e "$f" ]] || continue
            [[ -e "$d/$(basename "$f")" ]] || removed[$v]=1
        done
    done

    local failed=""
    for v in "${vers[@]}"; do
        [[ -n "${changed[$v]:-}" ]] || continue
        if ! sudo -n "/usr/sbin/php-fpm$v" -t >/dev/null 2>"$PHP_DIR/$v/.validate.err"; then
            failed="$v"; break
        fi
    done
    if [[ -n "$failed" ]]; then
        local msg; msg="$(tail -3 "$PHP_DIR/$failed/.validate.err" 2>/dev/null)"
        for v in "${vers[@]}"; do
            local d="$PHP_DIR/$v" bak="$PHP_DIR/$v/.bak"
            find "$d" -maxdepth 1 -name 'paas-*.conf' -delete
            if compgen -G "$bak/paas-*.conf" >/dev/null; then mv "$bak"/paas-*.conf "$d"/; fi
            rm -rf "$bak" "$d/.validate.err"
        done
        die "generated php-fpm pool config failed validation (PHP $failed) — old config kept. $msg"
    fi
    for v in "${vers[@]}"; do rm -rf "$PHP_DIR/$v/.bak" "$PHP_DIR/$v/.validate.err"; done

    # losing side first (frees the socket path), then the gaining side
    local any_removed=0
    for v in "${vers[@]}"; do
        [[ -n "${changed[$v]:-}" && -n "${removed[$v]:-}" ]] || continue
        sudo -n systemctl reload "php$v-fpm"; any_removed=1
    done
    [[ "$any_removed" -eq 1 ]] && sleep 1
    for v in "${vers[@]}"; do
        [[ -n "${changed[$v]:-}" && -z "${removed[$v]:-}" ]] || continue
        sudo -n systemctl reload "php$v-fpm"
    done
    log "php-fpm pools regenerated ($(find "$PHP_DIR" -maxdepth 2 -name 'paas-*.conf' | wc -l | tr -d ' ') laravel apps)"
}

vcl_name() { printf 'b_%s' "${1//-/_}"; }

regen_vcl() {
    local backends="$VCL_DIR/paas-backends.vcl.new" routing="$VCL_DIR/paas-routing.vcl.new"
    local have_cached=0
    rm -f "$backends.frag" "$routing.frag"

    {
        echo "# GENERATED by paas $(date -u +%FT%TZ) — do not edit."
    } >"$backends"
    {
        echo "# GENERATED by paas $(date -u +%FT%TZ) — do not edit."
    } >"$routing"

    local conf
    for conf in "$REG_DIR"/*.conf; do
        [[ -e "$conf" ]] || continue
        # shellcheck disable=SC1090
        ( reset_app_vars
          source "$conf"
          [[ "${CACHE:-off}" == "on" ]] || exit 0
          local_name="$(vcl_name "$APP")"
          {
              echo "probe p_${local_name#b_} {"
              echo "    .url = \"${HEALTH_PATH:-/}\";"
              echo "    .interval = 5s; .timeout = 2s; .window = 5; .threshold = 3; .initial = 3;"
              echo "}"
              echo "backend $local_name {"
              echo "    .host = \"127.0.0.1\"; .port = \"$PORT\";"
              echo "    .probe = p_${local_name#b_};"
              echo "    .connect_timeout = 3s; .first_byte_timeout = 60s; .between_bytes_timeout = 30s;"
              echo "}"
          } >>"$backends.frag"
          host_cond=""
          IFS=',' read -ra doms <<<"$(all_domains)"
          for d in "${doms[@]}"; do
              [[ -n "$host_cond" ]] && host_cond+=" || "
              host_cond+="req.http.host == \"$d\""
          done
          {
              echo "if ($host_cond) {"
              echo "    set req.backend_hint = $local_name;"
              if [[ -n "${PASS_PATHS:-}" ]]; then
                  echo "    if (req.url ~ \"$PASS_PATHS\") { return (pass); }"
              fi
              echo "}"
              echo "else"
          } >>"$routing.frag"
        )
    done

    if [[ -s "$backends.frag" ]]; then
        have_cached=1
        cat "$backends.frag" >>"$backends"
    else
        echo "backend b_paas_none none;" >>"$backends"
    fi
    if [[ -s "$routing.frag" ]]; then
        # fragments are "if (host…) { … }\nelse" — concatenated they chain,
        # and the final else branch handles unknown hosts
        cat "$routing.frag" >>"$routing"
        echo "{ return (synth(421, \"Misdirected Request\")); }" >>"$routing"
    else
        echo "return (synth(421, \"Misdirected Request\"));" >>"$routing"
    fi
    rm -f "$backends.frag" "$routing.frag"

    # stage -> validate -> commit (restore on failure)
    local b_old="$VCL_DIR/paas-backends.vcl" r_old="$VCL_DIR/paas-routing.vcl"
    cp -a "$b_old" "$b_old.bak" 2>/dev/null || true
    cp -a "$r_old" "$r_old.bak" 2>/dev/null || true
    mv "$backends" "$b_old"
    mv "$routing" "$r_old"
    if ! sudo /usr/sbin/varnishd -C -f /etc/varnish/default.vcl >/dev/null 2>"$VCL_DIR/.validate.err"; then
        [[ -f "$b_old.bak" ]] && mv "$b_old.bak" "$b_old"
        [[ -f "$r_old.bak" ]] && mv "$r_old.bak" "$r_old"
        die "generated VCL failed validation — old config kept. $(tail -3 "$VCL_DIR/.validate.err" 2>/dev/null)"
    fi
    rm -f "$b_old.bak" "$r_old.bak" "$VCL_DIR/.validate.err"
    sudo systemctl reload varnish
    log "varnish config regenerated ($(grep -c '^backend b_' "$b_old" 2>/dev/null || echo 0) cached app backends)"
}

regen_caddy() {
    local staged="$CADDY_DIR/.staged"
    rm -rf "$staged"; mkdir -p "$staged"

    local conf
    for conf in "$REG_DIR"/*.conf; do
        [[ -e "$conf" ]] || continue
        # shellcheck disable=SC1090
        ( reset_app_vars
          source "$conf"
          upstream="127.0.0.1:$PORT"
          [[ "${CACHE:-off}" == "on" ]] && upstream="$VARNISH_ADDR"
          alldoms="$(all_domains)"
          domains_spaced="${alldoms//,/, }"
          {
              echo "# GENERATED by paas — do not edit. App: $APP"
              if [[ -n "${REDIRECT_DOMAINS:-}" ]]; then
                  primary="$(primary_domain)"
                  IFS=',' read -ra rdoms <<<"$REDIRECT_DOMAINS"
                  for rd in "${rdoms[@]}"; do
                      echo "$rd {"
                      echo "    redir https://$primary{uri} 308"
                      echo "}"
                  done
              fi
              if is_laravel; then
                  # loopback listeners (never public): PORT = what wait_healthy,
                  # the Checkmk health probe and the panel status hit — same
                  # contract as a PM2 app; PORT+1000 = the deploy health-gate
                  # target (root only exists while a deploy is in flight).
                  # No log block: the public site already logs every request
                  # (a second log would double-count in paas-traffic-rollup).
                  echo "http://:$PORT {"
                  echo "    bind 127.0.0.1"
                  emit_caddy_php_site "$APPS_ROOT/$APP/current/public"
                  echo "}"
                  echo "http://:$((PORT + 1000)) {"
                  echo "    bind 127.0.0.1"
                  emit_caddy_php_site "$APPS_ROOT/$APP/staging/public"
                  echo "}"
              fi
              if [[ "${ENABLED:-on}" == "off" ]]; then
                  echo "$domains_spaced {"
                  echo "    respond \"Liftoff: this project is currently deactivated\" 503"
                  echo "}"
                  exit 0
              fi
              echo "$domains_spaced {"
              if [[ -n "${BASIC_AUTH_USER:-}" && -n "${BASIC_AUTH_HASH:-}" ]]; then
                  echo "    basic_auth {"
                  echo "        $BASIC_AUTH_USER $BASIC_AUTH_HASH"
                  echo "    }"
              fi
              # robots.txt deny: X-Robots-Tag only stops indexing — this stops
              # the crawl itself. Whole app when NOINDEX=on, else only the
              # system domain (custom production domains keep the app's robots).
              if [[ "${NOINDEX:-off}" == "on" ]]; then
                  echo "    handle /robots.txt {"
                  echo "        header Content-Type \"text/plain; charset=utf-8\""
                  echo "        respond <<ROBOTS"
                  echo "            User-agent: *"
                  echo "            Disallow: /"
                  echo "            ROBOTS 200"
                  echo "    }"
              elif [[ -n "${SYSTEM_DOMAIN:-}" ]]; then
                  echo "    @paas_robots_sys {"
                  echo "        host $SYSTEM_DOMAIN"
                  echo "        path /robots.txt"
                  echo "    }"
                  echo "    handle @paas_robots_sys {"
                  echo "        header Content-Type \"text/plain; charset=utf-8\""
                  echo "        respond <<ROBOTS"
                  echo "            User-agent: *"
                  echo "            Disallow: /"
                  echo "            ROBOTS 200"
                  echo "    }"
              fi
              emit_geo_directives "$APPS_ROOT/$APP/shared/geo.json"
              emit_domain_path_blocks "$APPS_ROOT/$APP/shared/domain-rules.json"
              if is_laravel; then
                  # PHP served directly by the public site (real REMOTE_ADDR,
                  # HTTPS=on — no TrustProxies needed); public/ assets by
                  # file_server from the live release, Vite output immutable.
                  echo "    @paas_build path /build/*"
                  echo "    header @paas_build Cache-Control \"public, max-age=31536000, immutable\""
                  echo "    handle {"
                  emit_caddy_php_site "$APPS_ROOT/$APP/current/public" "        "
                  echo "    }"
              else
                  echo "    @immutable {"
                  echo "        path /_nuxt/*"
                  echo "        file {"
                  echo "            root $APPS_ROOT/$APP/static"
                  echo "        }"
                  echo "    }"
                  echo "    handle @immutable {"
                  echo "        root * $APPS_ROOT/$APP/static"
                  echo "        header Cache-Control \"public, max-age=31536000, immutable\""
                  echo "        file_server {"
                  echo "            precompressed br gzip"
                  echo "        }"
                  echo "    }"
                  echo "    @static {"
                  echo "        path /images/* /fonts/* /logo/* /icons/* /favicon.ico"
                  echo "        file {"
                  echo "            root $APPS_ROOT/$APP/static"
                  echo "        }"
                  echo "    }"
                  echo "    handle @static {"
                  echo "        root * $APPS_ROOT/$APP/static"
                  echo "        header Cache-Control \"public, max-age=86400\""
                  echo "        file_server {"
                  echo "            precompressed br gzip"
                  echo "        }"
                  echo "    }"
                  echo "    handle {"
                  echo "        reverse_proxy $upstream"
                  echo "    }"
              fi
              if [[ "${NOINDEX:-off}" == "on" ]]; then
                  echo "    header X-Robots-Tag \"noindex, nofollow\""
              elif [[ -n "${SYSTEM_DOMAIN:-}" ]]; then
                  # system domain must never be indexed even when the app is
                  echo "    @paas_sysdom host $SYSTEM_DOMAIN"
                  echo "    header @paas_sysdom X-Robots-Tag \"noindex, nofollow\""
              fi
              emit_domain_noindex_headers "$APPS_ROOT/$APP/shared/domain-rules.json"
              echo "    header -Server"
              echo "    encode zstd gzip"
              echo "    log {"
              echo "        output file /srv/paas/logs/caddy/$APP.access.log {"
              echo "            roll_size 100MiB"
              echo "            roll_keep 3"
              echo "            mode 0664"
              echo "        }"
              echo "        format json"
              echo "    }"
              echo "}"
          } >"$staged/app-$APP.caddy"
        )
    done

    # stage -> validate -> commit
    local bak="$CADDY_DIR/.bak"
    rm -rf "$bak"; mkdir -p "$bak"
    find "$CADDY_DIR" -maxdepth 1 -name 'app-*.caddy' -exec mv {} "$bak"/ \;
    if compgen -G "$staged/app-*.caddy" >/dev/null; then
        mv "$staged"/app-*.caddy "$CADDY_DIR"/
    fi
    rm -rf "$staged"
    if ! "$CADDY_BIN" validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>"$CADDY_DIR/.validate.err"; then
        find "$CADDY_DIR" -maxdepth 1 -name 'app-*.caddy' -delete
        if compgen -G "$bak/app-*.caddy" >/dev/null; then mv "$bak"/app-*.caddy "$CADDY_DIR"/; fi
        rm -rf "$bak"
        die "generated Caddy config failed validation — old config kept. $(tail -3 "$CADDY_DIR/.validate.err" 2>/dev/null)"
    fi
    rm -rf "$bak" "$CADDY_DIR/.validate.err"
    sudo systemctl reload caddy
    log "caddy config regenerated"
}

# ----------------------------------------------------------------- runtime ----

write_run_sh() {
    cat >"$APP_DIR/run.sh" <<EOF
#!/usr/bin/env bash
# GENERATED by paas — do not edit. App: $APP
set -a
[ -f "$APP_DIR/shared/.env" ] && . "$APP_DIR/shared/.env"
set +a
${NODE_VERSION:+export PATH="/opt/node/v$NODE_VERSION/bin:\$PATH"}
export PORT=$PORT NITRO_PORT=$PORT HOST=127.0.0.1 NITRO_HOST=127.0.0.1 NODE_ENV=production
cd "$APP_DIR/current"
exec $START_CMD
EOF
    chmod +x "$APP_DIR/run.sh"
}

pm2_apply() { # start or restart the app under pm2
    if pm2 describe "$APP" >/dev/null 2>&1; then
        pm2 restart "$APP" --update-env >/dev/null
    else
        pm2 start "$APP_DIR/run.sh" --name "$APP" --interpreter bash --time >/dev/null
    fi
    pm2 save >/dev/null 2>&1 || true
}

# ---- laravel runtime ----

queue_name() { printf '%s-queue' "$APP"; }

write_queue_run_sh() { # pm2 wrapper for the queue worker
    # pm2 stops with SIGINT, which Laravel's worker does not trap (SIGTERM it
    # does) — relay so a redeploy/stop lets the current job finish.
    cat >"$APP_DIR/run-queue.sh" <<EOF
#!/usr/bin/env bash
# GENERATED by paas — do not edit. App: $APP (queue worker)
export PATH="$(php_path_prefix):\$PATH"
cd "$APP_DIR/current" || exit 1
${QUEUE_CMD:-$DEFAULT_QUEUE_CMD} &
child=\$!
trap 'kill -TERM "\$child" 2>/dev/null' INT TERM
wait "\$child"
EOF
    chmod +x "$APP_DIR/run-queue.sh"
}

queue_apply() { # reconcile the pm2 worker with QUEUE_WORKER
    local q; q="$(queue_name)"
    if [[ "${QUEUE_WORKER:-off}" == "on" ]]; then
        [[ -e "$APP_DIR/current" ]] || { log "queue worker: no release yet — starts with the first deploy"; return 0; }
        write_queue_run_sh
        if pm2 describe "$q" >/dev/null 2>&1; then
            pm2 restart "$q" --update-env >/dev/null
        else
            pm2 start "$APP_DIR/run-queue.sh" --name "$q" --interpreter bash --time --kill-timeout 60000 >/dev/null
        fi
    else
        pm2 delete "$q" >/dev/null 2>&1 || true
        rm -f "$APP_DIR/run-queue.sh"
    fi
    pm2 save >/dev/null 2>&1 || true
}

laravel_signal_queue() { # after a release switch: workers pick up the new code gracefully
    [[ "${QUEUE_WORKER:-off}" == "on" ]] || return 0
    if pm2 describe "$(queue_name)" >/dev/null 2>&1; then
        artisan "$APP_DIR/current" queue:restart >/dev/null 2>&1 \
            || log "warning: 'artisan queue:restart' failed (cache store not reachable?) — worker keeps old code until its --max-time"
    else
        queue_apply
    fi
}

ensure_laravel_shared() { # storage/ + .env live outside the releases
    local s="$APP_DIR/shared/storage"
    mkdir -p "$s/app/public" "$s/framework/cache/data" "$s/framework/sessions" \
             "$s/framework/views" "$s/framework/testing" "$s/logs"
    [[ -f "$APP_DIR/shared/.env" ]] || touch "$APP_DIR/shared/.env"
    chmod 600 "$APP_DIR/shared/.env"
}

link_laravel_release() { # $1 = release dir
    local rel="$1"
    rm -rf "$rel/storage" "$rel/.env" "$rel/public/storage"
    ln -sfn "$APP_DIR/shared/storage" "$rel/storage"
    ln -sfn "$APP_DIR/shared/.env" "$rel/.env"
    ln -sfn "$APP_DIR/shared/storage/app/public" "$rel/public/storage"
    mkdir -p "$rel/bootstrap/cache"
}

laravel_build_caches() { # $1 = release dir — also the "does it boot" smoke test
    local rel="$1" c
    for c in config:cache route:cache view:cache event:cache; do
        log "artisan $c"
        artisan "$rel" "$c" || return 1
    done
}

laravel_seed_env() { # first-time defaults so config:cache works before the user edits anything
    local env="$APP_DIR/shared/.env"
    [[ -n "$(env_get_key "$env" APP_KEY)" ]]    || env_set_key "$env" APP_KEY "base64:$(openssl rand -base64 32)"
    [[ -n "$(env_get_key "$env" APP_ENV)" ]]    || env_set_key "$env" APP_ENV production
    [[ -n "$(env_get_key "$env" APP_DEBUG)" ]]  || env_set_key "$env" APP_DEBUG false
    [[ -n "$(env_get_key "$env" APP_URL)" ]]    || env_set_key "$env" APP_URL "https://$(primary_domain)"
    [[ -n "$(env_get_key "$env" LOG_CHANNEL)" ]] || env_set_key "$env" LOG_CHANNEL daily
}

# ---- release activation (both runtimes) ----

runtime_activate() { # make `current` the served release
    if is_laravel; then
        laravel_signal_queue      # fpm needs nothing: resolve_root_symlink + per-release opcache keys
    else
        write_run_sh
        pm2_apply
    fi
}

activate_release() { # $1 = release, $2 = previous release (may be empty) — returns 1 if reverted
    local rel="$1" prev="${2:-}"
    ln -sfn "$rel" "$APP_DIR/current"
    runtime_activate
    if wait_healthy "$PORT" "$HEALTH_TIMEOUT"; then
        return 0
    fi
    log "app unhealthy after activation — rolling back symlink"
    if [[ -n "$prev" && -d "$prev" ]]; then
        ln -sfn "$prev" "$APP_DIR/current"
        runtime_activate
    fi
    return 1
}

wait_healthy() { # $1 = port, $2 = timeout seconds
    local port="$1" timeout="$2" path="${HEALTH_PATH:-/}" i=0
    while (( i < timeout )); do
        if curl -fsS -o /dev/null --max-time 2 "http://127.0.0.1:$port$path"; then
            return 0
        fi
        sleep 1; i=$((i+1))
    done
    return 1
}

ban_app() {
    [[ "${CACHE:-off}" == "on" ]] || return 0
    local d
    IFS=',' read -ra doms <<<"$(all_domains)"
    for d in "${doms[@]}"; do
        # negative lookahead spares /api/img objects: their sources are
        # content-hashed (immutable, 1y TTL) and rebuilding them after every
        # deploy re-fetched + re-encoded every teaser image for nothing.
        # For apps without /api/img the regex degrades to a full host ban.
        curl -fsS -o /dev/null -X BAN -H "X-Ban-Host: $d" -H 'X-Ban-Path: ^/(?!api/img)' "http://$VARNISH_ADDR/" \
            && log "cache purged for $d (kept /api/img)" \
            || log "warning: cache purge failed for $d (varnish down?)"
    done
}

warm_app() { # re-populate the cache so the next visitor gets a HIT
    [[ "${CACHE:-off}" == "on" ]] || return 0
    local d p i ok
    IFS=',' read -ra doms <<<"$(all_domains)"
    IFS=',' read -ra paths <<<"${WARM_PATHS:-/}"
    for d in "${doms[@]}"; do
        for p in "${paths[@]}"; do
            # retry: right after a deploy Varnish may still be probing the
            # fresh backend (sick -> 503 for up to ~15s)
            ok=0
            for i in 1 2 3 4 5; do
                if curl -fsS -o /dev/null --max-time 30 -H "Host: $d" "http://$VARNISH_ADDR$p" 2>/dev/null; then
                    ok=1; break
                fi
                [[ "$i" -lt 5 ]] && sleep 4
            done
            if [[ "$ok" -eq 1 ]]; then
                log "warmed $d$p"
            else
                log "warning: warm failed for $d$p (after $i attempts)"
            fi
        done
    done
    # never let warming fail a deploy that already activated
    warm_from_sitemap || true
}

# Sitemap-driven warm (WARM_SITEMAP=/news-sitemap.xml): the deploy ban wipes
# pages whose app-side TTLs are effectively "until purged" — without this,
# WARM_PATHS (default /) re-rendered only the homepage and every real visitor
# after a deploy paid a cold SSR miss. Warms the primary domain only; the
# system domain sees no organic traffic worth pre-rendering.
warm_from_sitemap() {
    [[ -n "${WARM_SITEMAP:-}" ]] || return 0
    local prim xml n
    prim="$(primary_domain)"
    [[ -n "$prim" ]] || return 0
    xml="$(curl -fsS --max-time 30 -H "Host: $prim" "http://$VARNISH_ADDR$WARM_SITEMAP" 2>/dev/null || true)"
    if [[ -z "$xml" ]]; then
        log "warning: sitemap warm skipped ($prim$WARM_SITEMAP unreachable)"
        return 0
    fi
    # `|| true` everywhere: an EMPTY sitemap is normal (ran/can news sitemaps
    # carry only syndicated content) — grep exits 1 on no match and under
    # `set -euo pipefail` that aborted the whole deploy AFTER activation,
    # recording a live release as failed.
    n="$( { grep -o '<loc>[^<]*</loc>' <<<"$xml" \
        | sed -e 's|<loc>||' -e 's|</loc>||' -e 's|&amp;|\&|g' -E -e 's|^https?://[^/]+||' -e 's|^$|/|' \
        | sort -u | head -n "${WARM_SITEMAP_LIMIT:-50}" \
        | xargs -r -P 3 -I{} sh -c \
            "curl -fsS -o /dev/null --max-time 30 -H 'Host: $prim' 'http://$VARNISH_ADDR{}' 2>/dev/null && echo ok" \
        | wc -l | tr -d ' '; } 2>/dev/null || true)"
    log "warmed ${n:-0} sitemap URLs for $prim (source $WARM_SITEMAP)"
}

deploy_record_update() { # $1 = status, $2 = step, $3 = release_dir (optional)
    local extra=""
    [[ -n "${3:-}" ]] && extra=", release_dir='$(sq "$3")'"
    [[ "$1" == "live" || "$1" == "failed" ]] && extra="$extra, finished_at=datetime('now')"
    db_exec "UPDATE deployments SET status='$(sq "$1")', step='$(sq "$2")'$extra WHERE id=$DEPLOY_ID;"
}

# ---------------------------------------------------------------- commands ----

cmd_create() {
    local app="" domains="" cache=off noindex=off port="" start_cmd="node server/index.mjs"
    local health="" pass_paths="" warm_paths="/" warm_sitemap="" redirect_domains="" system_domain="" node_version=""
    local runtime="node" php_version=""
    app="${1:-}"; shift || true
    [[ -n "$app" ]] || usage 1
    require_app_name "$app"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --domains)          domains="$2"; shift 2 ;;
            --redirect-domains) redirect_domains="$2"; shift 2 ;;
            --cache)            cache=on; shift ;;
            --noindex)          noindex=on; shift ;;
            --port)             port="$2"; shift 2 ;;
            --start)            start_cmd="$2"; shift 2 ;;
            --health)           health="$2"; shift 2 ;;
            --pass-paths)       pass_paths="$2"; shift 2 ;;
            --warm-paths)       warm_paths="$2"; shift 2 ;;
            --warm-sitemap)     warm_sitemap="$2"; shift 2 ;;
            --node)             node_version="$2"; shift 2 ;;
            --runtime)          runtime="$2"; shift 2 ;;
            --php)              php_version="$2"; shift 2 ;;
            *) die "unknown option '$1'" ;;
        esac
    done
    [[ -f "$(conf_path "$app")" ]] && die "app '$app' already exists"
    [[ -n "$port" ]] || port="$(next_free_port)"
    [[ "$runtime" =~ ^(node|laravel)$ ]] || die "--runtime must be node or laravel"
    require_runtime_support "$runtime"
    if [[ "$runtime" == "laravel" ]]; then
        # base.vcl strips cookies on cacheable paths — fatal for Laravel sessions/CSRF
        [[ "$cache" == "off" ]] || die "--cache is not supported for the laravel runtime (Varnish strips cookies)"
        php_version="${php_version:-$PHP_DEFAULT}"
        [[ "$php_version" =~ ^[0-9]+\.[0-9]+$ ]] || die "invalid --php '$php_version' (e.g. 8.3)"
        [[ -d "$PHP_DIR/$php_version" && -x "/usr/bin/php$php_version" ]] || die "PHP $php_version not installed ($PHP_DIR/$php_version missing)"
        [[ -n "$health" ]] || health="/up"     # Laravel 11+ ships /up; older apps: --health /
        start_cmd=""
    else
        php_version=""
        [[ -n "$health" ]] || health="/"
    fi

    # always-available system domain via the *.SYSTEM_DOMAIN_BASE wildcard
    if [[ -n "${SYSTEM_DOMAIN_BASE:-}" ]]; then
        system_domain="$app-$(openssl rand -hex 4).$SYSTEM_DOMAIN_BASE"
    fi
    [[ -n "$domains$system_domain" ]] || die "--domains is required (no SYSTEM_DOMAIN_BASE configured)"
    if [[ -n "$node_version" && "$node_version" != "system" ]]; then
        [[ -d "/opt/node/v$node_version" ]] || die "node version $node_version not installed (/opt/node/v$node_version missing)"
    fi
    [[ "$node_version" == "system" ]] && node_version=""

    mkdir -p "$APPS_ROOT/$app"/{releases,shared,static}
    touch "$APPS_ROOT/$app/shared/.env"

    cat >"$(conf_path "$app")" <<EOF
# paas app registry — managed by 'paas'; edit then run 'paas regen'
APP=$app
PORT=$port
DOMAINS=$domains
SYSTEM_DOMAIN=$system_domain
REDIRECT_DOMAINS=$redirect_domains
CACHE=$cache
NOINDEX=$noindex
START_CMD="$start_cmd"
HEALTH_PATH=$health
PASS_PATHS="$pass_paths"
WARM_PATHS="$warm_paths"
WARM_SITEMAP="$warm_sitemap"
NODE_VERSION=$node_version
RUNTIME=$runtime
PHP_VERSION=$php_version
EOF
    if [[ "$runtime" == "laravel" ]]; then
        cat >>"$(conf_path "$app")" <<EOF
MIGRATE=on
QUEUE_WORKER=off
QUEUE_CMD="$DEFAULT_QUEUE_CMD"
SCHEDULER=off
FPM_MAX_CHILDREN=8
EOF
    fi

    load_app "$app"
    if is_laravel; then
        ensure_laravel_shared
        laravel_seed_env
        regen_fpm
    else
        write_run_sh
    fi
    regen_caddy
    regen_vcl
    log "app '$app' created: runtime $runtime${php_version:+ (PHP $php_version)}, port $port, cache $cache, domains $(all_domains)"
    [[ -n "$system_domain" ]] && log "system domain (always available, noindex): https://$system_domain"
    if is_laravel; then
        log "APP_KEY generated in $APP_DIR/shared/.env — add a database with 'paas db create $app'"
        log "next: point DNS for custom domains at this server, then 'paas deploy $app <built-checkout>'"
    else
        log "next: point DNS for custom domains at this server, then 'paas deploy $app <output-dir>'"
    fi
}

cmd_deploy() {
    load_app "${1:-}" ; local src="${2:-}"
    [[ -n "$src" && -d "$src" ]] || die "usage: paas deploy <app> <built-output-dir>"
    if is_laravel; then
        [[ -f "$src/artisan" && -d "$src/vendor" && -d "$src/public" ]] \
            || die "$src is not a built Laravel checkout (need artisan, vendor/, public/ — run composer install first)"
    else
        [[ -e "$src/server/index.mjs" ]] || log "note: $src has no server/index.mjs — make sure START_CMD matches"
    fi

    local source_tag="${PAAS_SOURCE:-manual}" sha="${PAAS_COMMIT:-}"
    if [[ -z "${DEPLOY_ID:-}" ]]; then
        local logfile
        logfile="$LOG_DIR/deploy-$APP-$(date +%s).log"
        exec > >(tee -a "$logfile") 2>&1
        DEPLOY_ID="$(db_query "INSERT INTO deployments (app, source, commit_sha, status, step, log_path)
            VALUES ('$(sq "$APP")','$(sq "$source_tag")','$(sq "$sha")','deploying','starting','$(sq "$logfile")');
            SELECT last_insert_rowid();")"
        DEPLOY_ID="${DEPLOY_ID:-0}"
    fi
    [[ "$DEPLOY_ID" =~ ^[0-9]+$ ]] || DEPLOY_ID=0

    local ts rel prev
    ts="$(date +%Y%m%d%H%M%S)"
    rel="$APP_DIR/releases/$ts-${sha:0:8}"
    [[ -n "$sha" ]] || rel="$APP_DIR/releases/$ts"
    prev="$(readlink -f "$APP_DIR/current" 2>/dev/null || true)"

    deploy_record_update deploying "copying release"
    log "creating release $rel"
    mkdir -p "$rel"
    local test_port=$((PORT + 1000))
    if is_laravel; then
        # whole built checkout; storage/ and .env are shared, env-specific
        # caches are rebuilt below (packages.php/services.php stay: they come
        # from composer's package:discover and are environment-independent)
        rsync -a --delete-excluded \
            --exclude=/.git --exclude=/node_modules --exclude=/storage --exclude=/.env \
            --exclude=/tests --exclude='/bootstrap/cache/config.php' \
            --exclude='/bootstrap/cache/routes-*.php' --exclude='/bootstrap/cache/events.php' \
            "$src"/ "$rel"/
        ensure_laravel_shared
        link_laravel_release "$rel"

        deploy_record_update deploying "building laravel caches"
        if ! laravel_build_caches "$rel"; then
            deploy_record_update failed "artisan cache build failed (config/route/view/event)"
            die "artisan cache build failed — the release cannot boot; current version untouched"
        fi

        # health-gate through the REAL fpm pool: Caddy's loopback site on
        # PORT+1000 serves staging/public
        deploy_record_update deploying "health-gating new release"
        log "health-gating on port $test_port ($HEALTH_PATH) via staging symlink"
        ln -sfn "$rel" "$APP_DIR/staging"
        if ! wait_healthy "$test_port" "$HEALTH_TIMEOUT"; then
            rm -f "$APP_DIR/staging"
            deploy_record_update failed "health-gate failed (see shared/storage/logs/laravel.log)"
            die "new release failed the health-gate on port $test_port — current version untouched. See $APP_DIR/shared/storage/logs/laravel.log and php-fpm.log"
        fi
        rm -f "$APP_DIR/staging"

        if [[ "${MIGRATE:-on}" == "on" ]]; then
            deploy_record_update deploying "running migrations"
            log "artisan migrate --force"
            if ! artisan "$rel" migrate --force; then
                deploy_record_update failed "migrations failed (current release untouched; schema changes are NOT rolled back)"
                die "migrations failed — current version untouched (applied migrations stay applied)"
            fi
        fi
    else
        rsync -a "$src"/ "$rel"/

        if [[ -d "$rel/public" ]]; then
            log "syncing static assets (additive)"
            rsync -a "$rel/public/" "$APP_DIR/static/"
        fi

        # health-gate the new release on a test port before it goes live
        deploy_record_update deploying "health-gating new release"
        log "health-gating on port $test_port ($HEALTH_PATH)"
        ( set -a
          [ -f "$APP_DIR/shared/.env" ] && . "$APP_DIR/shared/.env"
          set +a
          [[ -n "${NODE_VERSION:-}" ]] && export PATH="/opt/node/v$NODE_VERSION/bin:$PATH"
          export PORT="$test_port" NITRO_PORT="$test_port" HOST=127.0.0.1 NITRO_HOST=127.0.0.1 NODE_ENV=production
          cd "$rel"
          exec bash -c "exec $START_CMD"
        ) >"$rel/.healthcheck.log" 2>&1 &
        local test_pid=$!
        if ! wait_healthy "$test_port" "$HEALTH_TIMEOUT"; then
            kill "$test_pid" 2>/dev/null || true
            deploy_record_update failed "health-gate failed (see release .healthcheck.log)"
            die "new release failed the health-gate on port $test_port — current version untouched. Log: $rel/.healthcheck.log"
        fi
        kill "$test_pid" 2>/dev/null || true
        wait "$test_pid" 2>/dev/null || true
    fi

    # go live
    deploy_record_update deploying "activating release"
    if ! activate_release "$rel" "$prev"; then
        deploy_record_update failed "unhealthy after activation, reverted"
        die "deploy failed after activation; previous release restored"
    fi

    ban_app
    warm_app

    # prune old releases (never the active one)
    local keep_list r
    keep_list="$(ls -1dt "$APP_DIR/releases"/*/ 2>/dev/null | head -n "$KEEP_RELEASES")"
    for r in "$APP_DIR/releases"/*/; do
        r="${r%/}"
        if ! grep -q "^${r}/$" <<<"$keep_list" && [[ "$(readlink -f "$APP_DIR/current")" != "$r" ]]; then
            rm -rf "$r"
        fi
    done

    deploy_record_update live "deployed" "$rel"
    log "deploy OK — $APP is live ($(primary_domain))"
}

cmd_rollback() {
    load_app "${1:-}"
    local cur target
    cur="$(readlink -f "$APP_DIR/current" 2>/dev/null || true)"
    [[ -n "$cur" ]] || die "no current release"
    target="$(ls -1dt "$APP_DIR/releases"/*/ | sed 's:/$::' | awk -v c="$cur" 'found{print;exit} $0==c{found=1}')"
    [[ -n "$target" ]] || die "no older release to roll back to"

    log "rolling back: $cur -> $target"
    activate_release "$target" "$cur" || die "rollback target unhealthy — restored $cur"
    ban_app
    warm_app
    db_exec "INSERT INTO deployments (app, source, status, step, release_dir, finished_at)
             VALUES ('$(sq "$APP")','manual','live','rollback -> $(sq "$(basename "$target")")','$(sq "$target")',datetime('now'));"
    log "rollback OK — now serving $(basename "$target")"
}

cmd_domains() {
    load_app "${1:-}"; shift
    local conf; conf="$(conf_path "$APP")"
    require_domain() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || die "invalid domain '$1'"; }
    case "${1:-}" in
        --add)
            [[ -n "${2:-}" ]] || die "usage: paas domains <app> --add <domain>"
            require_domain "$2"
            tr ',' '\n' <<<"$DOMAINS" | grep -qx "$2" && die "domain already present"
            sed -i "s|^DOMAINS=.*|DOMAINS=${DOMAINS:+$DOMAINS,}$2|" "$conf" ;;
        --remove)
            [[ -n "${2:-}" ]] || die "usage: paas domains <app> --remove <domain>"
            local new
            # grep -vx legitimately exits 1 when the removed domain was the only
            # entry (nothing survives to select) — under set -e/pipefail that would
            # otherwise abort the whole function before the empty-result check below
            # ever runs. The `|| true` only neutralizes the propagated exit status;
            # captured stdout (the filtered list) is unaffected.
            new="$(tr ',' '\n' <<<"$DOMAINS" | grep -vx "$2" | paste -sd, -)" || true
            [[ -n "$new" || -n "${SYSTEM_DOMAIN:-}" ]] || die "cannot remove the last domain (no system domain exists)"
            sed -i "s|^DOMAINS=.*|DOMAINS=$new|" "$conf"
            # a removed domain cannot stay main
            if [[ "${MAIN_DOMAIN:-}" == "$2" ]]; then
                if grep -q '^MAIN_DOMAIN=' "$conf"; then sed -i "s|^MAIN_DOMAIN=.*|MAIN_DOMAIN=|" "$conf"; fi
            fi
            # a removed domain cannot keep a domain-rules.json entry either
            local rules_file="$APP_DIR/shared/domain-rules.json"
            if [[ -f "$rules_file" ]]; then
                python3 - "$rules_file" "$2" <<'PY' || true
import json, sys
path, removed = sys.argv[1], sys.argv[2]
try:
    d = json.load(open(path))
except Exception:
    sys.exit(0)
d["rules"] = [r for r in d.get("rules", []) if r.get("domain") != removed]
json.dump(d, open(path, "w"))
PY
            fi ;;
        --set-main)
            [[ -n "${2:-}" ]] || die "usage: paas domains <app> --set-main <domain>"
            tr ',' '\n' <<<"$(all_domains)" | grep -qx "$2" || die "'$2' is not a configured domain of $APP"
            if grep -q '^MAIN_DOMAIN=' "$conf"; then
                sed -i "s|^MAIN_DOMAIN=.*|MAIN_DOMAIN=$2|" "$conf"
            else
                echo "MAIN_DOMAIN=$2" >>"$conf"
            fi ;;
        --add-redirect)
            [[ -n "${2:-}" ]] || die "usage: paas domains <app> --add-redirect <domain>"
            require_domain "$2"
            tr ',' '\n' <<<"${REDIRECT_DOMAINS:-}" | grep -qx "$2" && die "redirect already present"
            if grep -q '^REDIRECT_DOMAINS=' "$conf"; then
                sed -i "s|^REDIRECT_DOMAINS=.*|REDIRECT_DOMAINS=${REDIRECT_DOMAINS:+$REDIRECT_DOMAINS,}$2|" "$conf"
            else
                echo "REDIRECT_DOMAINS=$2" >>"$conf"
            fi ;;
        --remove-redirect)
            [[ -n "${2:-}" ]] || die "usage: paas domains <app> --remove-redirect <domain>"
            local newr
            # same set -e/pipefail pitfall as --remove above
            newr="$(tr ',' '\n' <<<"${REDIRECT_DOMAINS:-}" | grep -vx "$2" | paste -sd, -)" || true
            sed -i "s|^REDIRECT_DOMAINS=.*|REDIRECT_DOMAINS=$newr|" "$conf" ;;
        *) die "usage: paas domains <app> --add|--remove|--set-main|--add-redirect|--remove-redirect <domain>" ;;
    esac
    _keep="$APP"; reset_app_vars; load_app "$_keep"
    regen_caddy
    regen_vcl
    log "domains now: $(all_domains)  redirects: ${REDIRECT_DOMAINS:-—}"
}

cmd_ban()  { load_app "${1:-}"; CACHE=on ban_app; }

cmd_warm() { load_app "${1:-}"; CACHE=on warm_app; }

cmd_list() {
    printf '%-16s %-8s %-6s %-6s %-14s %s\n' APP RUNTIME PORT CACHE STATUS DOMAINS
    local conf status jlist
    jlist="$(pm2 jlist 2>/dev/null || echo '[]')"
    for conf in "$REG_DIR"/*.conf; do
        [[ -e "$conf" ]] || continue
        # shellcheck disable=SC1090
        ( reset_app_vars
          source "$conf"
          if is_laravel; then
              status="fpm:$(fpm_state)"
              if [[ "${QUEUE_WORKER:-off}" == "on" ]]; then
                  qs="$(jq -r --arg n "$APP-queue" '[.[]|select(.name==$n)][0].pm2_env.status // "-"' <<<"$jlist" 2>/dev/null || echo '-')"
                  status="$status+q:$qs"
              fi
          else
              status="$(jq -r --arg n "$APP" '[.[]|select(.name==$n)][0].pm2_env.status // "-"' <<<"$jlist" 2>/dev/null || echo '-')"
          fi
          printf '%-16s %-8s %-6s %-6s %-14s %s\n' "$APP" "${RUNTIME:-node}" "$PORT" "$CACHE" "$status" "$DOMAINS"
        )
    done
}

cmd_info() { load_app "${1:-}"; cat "$(conf_path "$APP")"; }

cmd_logs() {
    load_app "${1:-}"
    if is_laravel; then
        local files=() f
        for f in "$APP_DIR/shared/storage/logs/laravel.log" "$APP_DIR/shared/storage/logs/php-fpm.log" \
                 "$APP_DIR/shared/storage/logs/scheduler.log"; do
            [[ -f "$f" ]] && files+=("$f")
        done
        # daily channel: laravel-YYYY-MM-DD.log — include the newest
        local daily; daily="$(ls -1t "$APP_DIR"/shared/storage/logs/laravel-*.log 2>/dev/null | head -1 || true)"
        [[ -n "$daily" ]] && files+=("$daily")
        [[ ${#files[@]} -gt 0 ]] || die "no logs yet under $APP_DIR/shared/storage/logs"
        if [[ "${QUEUE_WORKER:-off}" == "on" ]]; then
            pm2 logs "$(queue_name)" --lines 50 --nostream 2>/dev/null || true
        fi
        exec tail -n 200 -F "${files[@]}"
    fi
    exec pm2 logs "$APP"
}

cmd_restart() { # apply env changes: node = pm2 restart; laravel = re-cache config + reload fpm
    load_app "${1:-}"
    if is_laravel; then
        [[ -e "$APP_DIR/current" ]] || die "no current release"
        laravel_build_caches "$APP_DIR/current" || die "artisan cache rebuild failed — app still runs the previous config cache"
        sudo -n systemctl reload "$(fpm_unit)"
        laravel_signal_queue
        [[ "${QUEUE_WORKER:-off}" == "on" ]] && pm2 restart "$(queue_name)" --update-env >/dev/null 2>&1 || true
        wait_healthy "$PORT" "$HEALTH_TIMEOUT" || die "app unhealthy after restart — check 'paas logs $APP'"
        log "$APP restarted (config cache rebuilt, php-fpm reloaded)"
    else
        write_run_sh
        pm2_apply
        wait_healthy "$PORT" "$HEALTH_TIMEOUT" || die "app unhealthy after restart — check 'paas logs $APP'"
        log "$APP restarted (pm2)"
    fi
}

cmd_remove() {
    local app="${1:-}" purge=0 yes=0; shift || true
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --purge) purge=1; shift ;;
            --yes)   yes=1; shift ;;
            *) die "unknown option '$1'" ;;
        esac
    done
    load_app "$app"
    if [[ "$yes" -ne 1 ]]; then
        read -rp "[paas] remove app '$APP' (domains: $DOMAINS)? [y/N] " a
        [[ "$a" == "y" || "$a" == "Y" ]] || die "aborted"
    fi
    # capture domains now: after conf removal they are gone
    local _all _redirects; _all="$(all_domains)"; _redirects="${REDIRECT_DOMAINS:-}"
    # purge any cached pages for this app's hosts before the config vanishes
    CACHE=on ban_app || true
    pm2 delete "$APP" >/dev/null 2>&1 || true
    pm2 delete "$(queue_name)" >/dev/null 2>&1 || true
    pm2 save >/dev/null 2>&1 || true
    local _was_laravel=0; is_laravel && _was_laravel=1
    rm -f "$(conf_path "$APP")"
    regen_caddy
    regen_vcl
    [[ "$_was_laravel" -eq 1 ]] && regen_fpm
    if [[ "$purge" -eq 1 ]]; then
        # database (if any): backup first, the dump survives the purge
        if [[ -f "$DB_ADMIN_CNF" ]] && db_exists "$(db_name_for "$APP")"; then
            db_backup_app "$APP" "$(db_name_for "$APP")" || log "warning: database backup failed — dropping anyway"
            db_drop_app "$APP" "$(db_name_for "$APP")"
        fi
        [[ -n "$APP" && -d "$APPS_ROOT/$APP" ]] && rm -rf "${APPS_ROOT:?}/${APP:?}"
        log "purged $APP_DIR"
        # drop caddy's stored certificates for exactly this app's domains
        local _d
        for _d in $(tr ',' ' ' <<<"$_all${_redirects:+,$_redirects}"); do
            if [[ $EUID -eq 0 ]]; then
                /usr/local/bin/paas-cert-prune "$_d" >/dev/null 2>&1 && log "removed certificate storage for $_d" || true
            else
                sudo -n /usr/local/bin/paas-cert-prune "$_d" >/dev/null 2>&1 && log "removed certificate storage for $_d" || true
            fi
        done
    fi
    log "app '$APP' removed"
}

cmd_ssl() { # force Caddy to re-attempt certificate issuance (resets ACME backoff)
    load_app "${1:-}"
    local only="${2:-}" list
    list="$(all_domains)${REDIRECT_DOMAINS:+,$REDIRECT_DOMAINS}"
    if [[ -n "$only" ]]; then
        tr ',' '\n' <<<"$list" | grep -qx "$only" || die "'$only' is not a domain of $APP"
        list="$only"
    fi
    log "forcing caddy re-provision to retry certificate issuance"
    # only names WITHOUT a valid cert are (re)attempted — existing certs stay untouched
    sudo systemctl reload caddy   # ExecReload uses --force -> re-provisions, retries missing certs
    sleep 6                        # issuance is async; give the first attempt a moment
    local d end
    IFS=',' read -ra doms <<<"$list"
    for d in "${doms[@]}"; do
        end=$(echo | timeout 5 openssl s_client -servername "$d" -connect 127.0.0.1:443 2>/dev/null \
              | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
        if [[ -n "$end" ]]; then
            log "OK: $d (valid until $end)"
        else
            log "PENDING: $d — no cert yet; if DNS points here, retry in a minute"
        fi
    done
}

cmd_geo() {
    load_app "${1:-}"; shift
    local geo_file="$APP_DIR/shared/geo.json"
    case "${1:-}" in
        --set-json)
            local src="${2:--}" tmp
            tmp="$(mktemp)"
            if [[ "$src" == "-" ]]; then cat >"$tmp"; else cat "$src" >"$tmp"; fi
            python3 - "$tmp" <<'PY' || { rm -f "$tmp"; die "invalid geo rules JSON"; }
import json, re, sys
d = json.load(open(sys.argv[1]))
assert isinstance(d.get("rules"), list)
for r in d["rules"]:
    assert r.get("mode") in ("block", "allow-only")
    assert isinstance(r.get("countries"), list) and r["countries"]
    for c in r["countries"]:
        assert re.match(r"^[A-Z]{2}$", str(c))
    for p in r.get("paths", []):
        assert str(p).startswith("/")
PY
            install -m 644 "$tmp" "$geo_file"; rm -f "$tmp" ;;
        --clear)
            rm -f "$geo_file" ;;
        --show)
            cat "$geo_file" 2>/dev/null || echo '{"rules": []}'; return 0 ;;
        *) die "usage: paas geo <app> --set-json <file|-> | --clear | --show" ;;
    esac
    regen_caddy
    log "geo rules updated for $APP"
}

# Restrict specific custom domains to a path allowlist (everything else
# 404s on that host) and/or mark them noindex independently of the
# app-wide NOINDEX flag. See emit_domain_path_blocks/emit_domain_noindex_headers.
cmd_domain_rules() {
    load_app "${1:-}"; shift
    local rules_file="$APP_DIR/shared/domain-rules.json"
    case "${1:-}" in
        --set-json)
            local src="${2:--}" tmp
            tmp="$(mktemp)"
            if [[ "$src" == "-" ]]; then cat >"$tmp"; else cat "$src" >"$tmp"; fi
            DOMAIN_RULES_KNOWN_DOMAINS="$DOMAINS" python3 - "$tmp" <<'PY' || { rm -f "$tmp"; die "invalid domain rules JSON"; }
import json, os, re, sys
path = sys.argv[1]
known = set(d for d in os.environ.get("DOMAIN_RULES_KNOWN_DOMAINS", "").split(",") if d)
d = json.load(open(path))
assert isinstance(d.get("rules"), list)
seen = set()
for r in d["rules"]:
    dom = r.get("domain")
    assert isinstance(dom, str) and dom, "each rule needs a domain"
    assert dom not in seen, f"duplicate rule for domain '{dom}'"
    assert dom in known, f"domain '{dom}' is not a configured DOMAINS entry for this app"
    seen.add(dom)
    paths = r.get("paths", [])
    assert isinstance(paths, list) and len(paths) <= 20
    for p in paths:
        assert re.match(r"^/[A-Za-z0-9_\-./*]+$", str(p)), f"invalid path '{p}'"
    assert isinstance(r.get("noindex", False), bool)
PY
            install -m 644 "$tmp" "$rules_file"; rm -f "$tmp" ;;
        --clear)
            rm -f "$rules_file" ;;
        --show)
            cat "$rules_file" 2>/dev/null || echo '{"rules": []}'; return 0 ;;
        *) die "usage: paas domain-rules <app> --set-json <file|-> | --clear | --show" ;;
    esac
    regen_caddy
    log "domain rules updated for $APP"
}

cmd_node() { # select the Node.js version an app builds/runs with
    load_app "${1:-}"; shift
    [[ "${1:-}" == "--set" && -n "${2:-}" ]] || die "usage: paas node <app> --set <version|system>"
    local v="$2"
    if [[ "$v" == "system" ]]; then v=""; else
        [[ -d "/opt/node/v$v" ]] || die "node version $v not installed (/opt/node/v$v missing)"
    fi
    local conf; conf="$(conf_path "$APP")"
    if grep -q '^NODE_VERSION=' "$conf"; then
        sed -i "s|^NODE_VERSION=.*|NODE_VERSION=$v|" "$conf"
    else
        echo "NODE_VERSION=$v" >>"$conf"
    fi
    _keep="$APP"; reset_app_vars; load_app "$_keep"
    if is_laravel; then
        log "node version set to ${v:-system} — used for the asset build (Vite) on the next deploy"
    else
        write_run_sh
        log "node version set to ${v:-system} — takes effect on next deploy or 'pm2 restart $APP'"
    fi
}

cmd_php() { # select the PHP version a laravel app builds/runs with
    load_app "${1:-}"; shift
    is_laravel || die "$APP is not a laravel app"
    [[ "${1:-}" == "--set" && -n "${2:-}" ]] || die "usage: paas php <app> --set <version>"
    local v="$2"
    [[ "$v" =~ ^[0-9]+\.[0-9]+$ && -d "$PHP_DIR/$v" && -x "/usr/bin/php$v" ]] || die "PHP $v not installed ($PHP_DIR/$v missing)"
    set_conf_key "$(conf_path "$APP")" PHP_VERSION "$v"
    _keep="$APP"; reset_app_vars; load_app "$_keep"
    regen_fpm                       # pool moves to the new master; socket path unchanged
    if [[ "${QUEUE_WORKER:-off}" == "on" && -e "$APP_DIR/current" ]]; then
        write_queue_run_sh; queue_apply
    fi
    log "PHP version set to $v — live now (fpm pool moved); rebuild caches with 'paas restart $APP' or the next deploy"
}

set_conf_key() { # $1 conf, $2 key, $3 value
    if grep -q "^$2=" "$1"; then sed -i "s|^$2=.*|$2=$3|" "$1"; else echo "$2=$3" >>"$1"; fi
}

cmd_set() { # toggle per-app registry flags; CACHE affects caddy upstream + vcl, NOINDEX affects caddy
    local set_usage="usage: paas set <app> [--cache on|off] [--noindex on|off] [--health <path>] [--warm-paths <p,p>] [--warm-sitemap <path>] [--geo-exempt-google on|off]
                      [--migrate on|off] [--queue on|off] [--queue-cmd <cmd>] [--scheduler on|off] [--max-children N]   (laravel)"
    load_app "${1:-}"; shift
    local conf changed=0 fpm_changed=0 queue_changed=0; conf="$(conf_path "$APP")"
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --cache)
                [[ "${2:-}" =~ ^(on|off)$ ]] || die "$set_usage"
                if [[ "$2" == "on" ]] && is_laravel; then die "Varnish cache is not supported for laravel apps (base.vcl strips cookies)"; fi
                set_conf_key "$conf" CACHE "$2"; changed=1; shift 2 ;;
            --migrate|--scheduler|--queue)
                is_laravel || die "$1 only applies to laravel apps"
                [[ "${2:-}" =~ ^(on|off)$ ]] || die "$set_usage"
                case "$1" in
                    --migrate)   set_conf_key "$conf" MIGRATE "$2" ;;
                    --scheduler) set_conf_key "$conf" SCHEDULER "$2" ;;
                    --queue)     set_conf_key "$conf" QUEUE_WORKER "$2"; queue_changed=1 ;;
                esac
                changed=1; shift 2 ;;
            --queue-cmd)
                is_laravel || die "$1 only applies to laravel apps"
                [[ -n "${2:-}" && "$2" != *'"'* ]] || die "$set_usage"
                set_conf_key "$conf" QUEUE_CMD "\"$2\""; changed=1; queue_changed=1; shift 2 ;;
            --max-children)
                is_laravel || die "$1 only applies to laravel apps"
                [[ "${2:-}" =~ ^[0-9]{1,3}$ && "$2" -ge 1 ]] || die "$set_usage"
                set_conf_key "$conf" FPM_MAX_CHILDREN "$2"; changed=1; fpm_changed=1; shift 2 ;;
            --noindex)
                [[ "${2:-}" =~ ^(on|off)$ ]] || die "$set_usage"
                set_conf_key "$conf" NOINDEX "$2"; changed=1; shift 2 ;;
            --geo-exempt-google)
                [[ "${2:-}" =~ ^(on|off)$ ]] || die "$set_usage"
                set_conf_key "$conf" GEO_EXEMPT_GOOGLE "$2"; changed=1; shift 2 ;;
            --health)
                # health probes hit this on the app port every 5s — a cheap
                # endpoint (e.g. /api/_health) beats a full SSR render of /
                [[ "${2:-}" == /* ]] || die "$set_usage"
                set_conf_key "$conf" HEALTH_PATH "$2"; changed=1; shift 2 ;;
            --warm-paths)
                [[ -n "${2:-}" ]] || die "$set_usage"
                set_conf_key "$conf" WARM_PATHS "\"$2\""; changed=1; shift 2 ;;
            --warm-sitemap)
                # empty string clears it
                set_conf_key "$conf" WARM_SITEMAP "\"${2:-}\""; changed=1; shift 2 ;;
            *) die "$set_usage" ;;
        esac
    done
    [[ "$changed" == "1" ]] || die "$set_usage"
    _keep="$APP"; reset_app_vars; load_app "$_keep"
    regen_caddy; regen_vcl
    [[ "$fpm_changed" == "1" ]] && regen_fpm
    [[ "$queue_changed" == "1" ]] && queue_apply
    if is_laravel; then
        log "$APP flags now NOINDEX=${NOINDEX:-off} HEALTH=${HEALTH_PATH:-/} MIGRATE=${MIGRATE:-on} QUEUE_WORKER=${QUEUE_WORKER:-off} SCHEDULER=${SCHEDULER:-off} FPM_MAX_CHILDREN=${FPM_MAX_CHILDREN:-8} GEO_EXEMPT_GOOGLE=${GEO_EXEMPT_GOOGLE:-on}"
    else
        log "$APP flags now CACHE=${CACHE:-off} NOINDEX=${NOINDEX:-off} HEALTH=${HEALTH_PATH:-/} WARM_SITEMAP=${WARM_SITEMAP:-} GEO_EXEMPT_GOOGLE=${GEO_EXEMPT_GOOGLE:-on} — caddy+varnish regenerated"
    fi
}

cmd_enable() {
    load_app "${1:-}"
    set_conf_key "$(conf_path "$APP")" ENABLED on
    _keep="$APP"; reset_app_vars; load_app "$_keep"; regen_caddy
    log "$APP enabled — publicly reachable again"
}

cmd_disable() {
    load_app "${1:-}"
    set_conf_key "$(conf_path "$APP")" ENABLED off
    _keep="$APP"; reset_app_vars; load_app "$_keep"; regen_caddy
    log "$APP disabled — all domains answer 503 (app keeps running internally)"
}

cmd_protect() {
    load_app "${1:-}"; shift
    local conf; conf="$(conf_path "$APP")"
    if [[ "${1:-}" == "--off" ]]; then
        set_conf_key "$conf" BASIC_AUTH_USER ""
        set_conf_key "$conf" BASIC_AUTH_HASH ""
        _keep="$APP"; reset_app_vars; load_app "$_keep"; regen_caddy
        log "$APP protection removed — public again"
        return 0
    fi
    local buser="" bpass=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --user) buser="$2"; shift 2 ;;
            --password) bpass="$2"; shift 2 ;;
            *) die "usage: paas protect <app> --user <u> --password <p> | --off" ;;
        esac
    done
    [[ -n "$buser" && -n "$bpass" ]] || die "usage: paas protect <app> --user <u> --password <p> | --off"
    [[ "$buser" =~ ^[A-Za-z0-9_.-]{2,40}$ ]] || die "invalid user"
    local hash
    hash="$("$CADDY_BIN" hash-password --plaintext "$bpass")" || die "hashing failed"
    set_conf_key "$conf" BASIC_AUTH_USER "$buser"
    set_conf_key "$conf" BASIC_AUTH_HASH "'$hash'"
    _keep="$APP"; reset_app_vars; load_app "$_keep"; regen_caddy
    log "$APP protected — browser login: $buser / <the password you set>"
}

# ---------------------------------------------------------- database (MariaDB) ----
# One database + one user per app, loopback only. Admin access goes through
# /srv/paas/db-admin.cnf (paas_admin, written by bootstrap WITH_LARAVEL=1) —
# no sudo, no root shell. Credentials live ONLY in the app's shared/.env.

DB_RESERVED="mysql information_schema performance_schema sys test"
# explicit list (not ALL): paas_admin holds exactly these with GRANT OPTION —
# "GRANT ALL" would fail because ALL also implies privileges we never gave it
DB_APP_PRIVS="SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX, REFERENCES, LOCK TABLES, CREATE VIEW, SHOW VIEW, TRIGGER, EVENT, CREATE ROUTINE, ALTER ROUTINE, EXECUTE, CREATE TEMPORARY TABLES"

require_db_server() {
    [[ -f "$DB_ADMIN_CNF" ]] || die "no database server on this host (bootstrap with WITH_LARAVEL=1 to install MariaDB)"
    command -v mariadb >/dev/null || command -v mysql >/dev/null || die "mariadb client not installed"
}

db_admin() { # run the mariadb client as paas_admin
    local bin=mariadb; command -v mariadb >/dev/null || bin=mysql
    "$bin" --defaults-extra-file="$DB_ADMIN_CNF" "$@"
}

db_dump_bin() { command -v mariadb-dump >/dev/null && echo mariadb-dump || echo mysqldump; }

db_name_for() { printf '%s' "${1//-/_}"; } # app names are ^[a-z][a-z0-9-]{1,30}$ → safe identifiers

db_guard_name() { # $1 = db name
    local r
    for r in $DB_RESERVED; do [[ "$1" == "$r" ]] && die "'$1' is a reserved database name"; done
    return 0
}

db_exists() { # $1 = db name
    [[ -n "$(db_admin -N -B -e "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$1'" 2>/dev/null)" ]]
}

db_backup_app() { # $1 = app, $2 = db name
    local app="$1" name="$2" ts out
    mkdir -p "$DB_BACKUP_DIR"; chmod 700 "$DB_BACKUP_DIR"
    ts="$(date +%Y%m%d%H%M%S)"
    out="$DB_BACKUP_DIR/$app-$ts.sql.gz"
    if ! "$(db_dump_bin)" --defaults-extra-file="$DB_ADMIN_CNF" --single-transaction --quick \
            --routines --triggers --events "$name" | gzip >"$out"; then
        rm -f "$out"
        return 1
    fi
    chmod 600 "$out"
    log "database backup: $out ($(du -h "$out" | cut -f1))"
    # keep the newest N per app
    ls -1t "$DB_BACKUP_DIR/$app"-*.sql.gz 2>/dev/null | tail -n +"$((DB_BACKUP_KEEP + 1))" | xargs -r rm -f
}

db_drop_app() { # $1 = app, $2 = db name — DROP db + user, forget creds in .env
    local app="$1" name="$2" k
    local env="$APPS_ROOT/$app/shared/.env"
    db_admin -e "DROP DATABASE IF EXISTS \`$name\`; DROP USER IF EXISTS '$name'@'localhost'; DROP USER IF EXISTS '$name'@'127.0.0.1'; FLUSH PRIVILEGES;"
    if [[ -f "$env" && "$(env_get_key "$env" DB_DATABASE)" == "$name" ]]; then
        for k in DB_CONNECTION DB_HOST DB_PORT DB_DATABASE DB_USERNAME DB_PASSWORD DATABASE_URL; do
            sed -i "/^$k=/d" "$env"
        done
    fi
    log "database $name and user dropped"
}

db_print_creds() { # $1 = db/user name, $2 = password
    cat <<EOF
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=$1
DB_USERNAME=$1
DB_PASSWORD=$2
# url form:      mysql://$1:$2@127.0.0.1:3306/$1
# from outside:  ssh -L 3306:127.0.0.1:3306 deploy@$(hostname -f 2>/dev/null || hostname)  (then connect to localhost:3306)
EOF
}

db_write_env() { # $1 = app, $2 = name, $3 = password, $4 = force (1|0)
    local env="$APPS_ROOT/$1/shared/.env" force="$4" k
    local -A want=( [DB_CONNECTION]=mysql [DB_HOST]=127.0.0.1 [DB_PORT]=3306 [DB_DATABASE]="$2" [DB_USERNAME]="$2" [DB_PASSWORD]="$3" )
    local skipped=()
    for k in DB_CONNECTION DB_HOST DB_PORT DB_DATABASE DB_USERNAME DB_PASSWORD; do
        if [[ "$force" != "1" && -n "$(env_get_key "$env" "$k")" && "$(env_get_key "$env" "$k")" != "${want[$k]}" ]]; then
            skipped+=("$k"); continue
        fi
        env_set_key "$env" "$k" "${want[$k]}"
    done
    # node apps: many libraries want a single URL (Prisma, knex, Strapi DATABASE_URL)
    if ! is_laravel; then
        if [[ "$force" == "1" || -z "$(env_get_key "$env" DATABASE_URL)" ]]; then
            env_set_key "$env" DATABASE_URL "mysql://$2:$3@127.0.0.1:3306/$2"
        else
            skipped+=(DATABASE_URL)
        fi
    fi
    if [[ ${#skipped[@]} -gt 0 ]]; then
        log "warning: kept existing ${skipped[*]} in shared/.env (differs from the provisioned values; use --force-env to overwrite)"
    fi
}

cmd_db() {
    local action="${1:-}"; shift || true
    case "$action" in
        backup)
            require_db_server
            if [[ "${1:-}" == "--all" ]]; then
                local conf n rc=0
                for conf in "$REG_DIR"/*.conf; do
                    [[ -e "$conf" ]] || continue
                    # shellcheck disable=SC1090
                    ( reset_app_vars; source "$conf"
                      n="$(db_name_for "$APP")"
                      db_exists "$n" || exit 0
                      db_backup_app "$APP" "$n" ) || rc=1
                done
                [[ "$rc" -eq 0 ]] || die "one or more backups failed"
                return 0
            fi
            load_app "${1:-}"
            local n; n="$(db_name_for "$APP")"
            db_exists "$n" || die "$APP has no database"
            db_backup_app "$APP" "$n" || die "backup failed" ;;
        create)
            require_db_server
            local force=0
            load_app "${1:-}"; shift || true
            [[ "${1:-}" == "--force-env" ]] && force=1
            local n env pw; n="$(db_name_for "$APP")"; env="$APP_DIR/shared/.env"
            db_guard_name "$n"
            if db_exists "$n"; then
                if [[ "$force" != "1" && "$(env_get_key "$env" DB_DATABASE)" == "$n" && -n "$(env_get_key "$env" DB_PASSWORD)" ]]; then
                    log "database $n already exists — current credentials:"
                    db_print_creds "$n" "$(env_get_key "$env" DB_PASSWORD)"
                    return 0
                fi
                log "database $n exists but shared/.env has no matching credentials — rotating the password"
            fi
            pw="$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 32)"
            # two host entries: apps connect over TCP to 127.0.0.1 (with
            # skip-name-resolve that never matches 'localhost'), tooling may use the socket
            db_admin -e "CREATE DATABASE IF NOT EXISTS \`$n\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
                         CREATE USER IF NOT EXISTS '$n'@'127.0.0.1' IDENTIFIED BY '$pw';
                         CREATE USER IF NOT EXISTS '$n'@'localhost' IDENTIFIED BY '$pw';
                         ALTER USER '$n'@'127.0.0.1' IDENTIFIED BY '$pw';
                         ALTER USER '$n'@'localhost' IDENTIFIED BY '$pw';
                         GRANT $DB_APP_PRIVS ON \`$n\`.* TO '$n'@'127.0.0.1';
                         GRANT $DB_APP_PRIVS ON \`$n\`.* TO '$n'@'localhost';
                         FLUSH PRIVILEGES;" || die "creating database $n failed"
            db_write_env "$APP" "$n" "$pw" "$force"
            log "database $n ready — credentials written to $env:"
            db_print_creds "$n" "$pw"
            is_laravel && log "apply with 'paas restart $APP' (config cache) or the next deploy"
            return 0 ;;
        show)
            require_db_server
            load_app "${1:-}"
            local n env; n="$(db_name_for "$APP")"; env="$APP_DIR/shared/.env"
            db_exists "$n" || die "$APP has no database (paas db create $APP)"
            [[ "$(env_get_key "$env" DB_DATABASE)" == "$n" ]] || die "database $n exists but shared/.env has no credentials — 'paas db create $APP' rotates them"
            db_print_creds "$n" "$(env_get_key "$env" DB_PASSWORD)" ;;
        drop)
            require_db_server
            local yes=0
            load_app "${1:-}"; shift || true
            [[ "${1:-}" == "--yes" ]] && yes=1
            local n; n="$(db_name_for "$APP")"
            db_exists "$n" || die "$APP has no database"
            if [[ "$yes" -ne 1 ]]; then
                read -rp "[paas] DROP database '$n' of $APP (a backup is taken first)? [y/N] " a
                [[ "$a" == "y" || "$a" == "Y" ]] || die "aborted"
            fi
            db_backup_app "$APP" "$n" || die "backup failed — database NOT dropped"
            db_drop_app "$APP" "$n" ;;
        shell)
            require_db_server
            load_app "${1:-}"
            local n env; n="$(db_name_for "$APP")"; env="$APP_DIR/shared/.env"
            [[ "$(env_get_key "$env" DB_DATABASE)" == "$n" ]] || die "$APP has no database credentials in shared/.env"
            local bin=mariadb; command -v mariadb >/dev/null || bin=mysql
            MYSQL_PWD="$(env_get_key "$env" DB_PASSWORD)" exec "$bin" -h 127.0.0.1 -u "$n" "$n" ;;
        *) die "usage: paas db create <app> [--force-env] | show <app> | drop <app> [--yes] | backup <app>|--all | shell <app>" ;;
    esac
}

cmd_regen() { regen_fpm; regen_caddy; regen_vcl; }

# ------------------------------------------------------------------- main ----

[[ "$(id -un)" == "root" ]] && die "run paas as the 'deploy' user, not root"
mkdir -p "$REG_DIR" "$CADDY_DIR" "$VCL_DIR" "$LOG_DIR"

case "${1:-}" in
    create)   shift; cmd_create "$@" ;;
    deploy)   shift; cmd_deploy "$@" ;;
    rollback) shift; cmd_rollback "$@" ;;
    domains)  shift; cmd_domains "$@" ;;
    domain-rules) shift; cmd_domain_rules "$@" ;;
    ban)      shift; cmd_ban "$@" ;;
    warm)     shift; cmd_warm "$@" ;;
    geo)      shift; cmd_geo "$@" ;;
    node)     shift; cmd_node "$@" ;;
    php)      shift; cmd_php "$@" ;;
    restart)  shift; cmd_restart "$@" ;;
    db)       shift; cmd_db "$@" ;;
    set)      shift; cmd_set "$@" ;;
    enable)   shift; cmd_enable "$@" ;;
    disable)  shift; cmd_disable "$@" ;;
    protect)  shift; cmd_protect "$@" ;;
    ssl)      shift; cmd_ssl "$@" ;;
    list)     shift; cmd_list "$@" ;;
    info)     shift; cmd_info "$@" ;;
    logs)     shift; cmd_logs "$@" ;;
    remove)   shift; cmd_remove "$@" ;;
    regen)    shift; cmd_regen "$@" ;;
    help|-h|--help|"") usage 0 ;;
    *) die "unknown command '${1}' (see 'paas help')" ;;
esac
