
#!/bin/bash
# ==============================================================================
# nc-audit.sh - Audit and optimization of a Nextcloud server (Debian)
# Author : ézéo - Digital cooperative
# License : AGPL-3.0
#
# Analyzes the server configuration and generates recommendations for
# Apache/Nginx, PHP-FPM, PostgreSQL/MariaDB and Nextcloud, taking the
# machine's physical capacity (RAM, CPU) into account.
#
# Distributed by NcStatusCheck (the "Server audit" page). Read before running:
# it runs as root and only READS the configuration (no modifications).
#
# Usage :
#   sudo bash nc-audit.sh [/path/to/nextcloud]
#   sudo bash nc-audit.sh /var/www/nextcloud            # path as argument
#   sudo NC_PATH=/var/www/nextcloud bash nc-audit.sh    # or via variable (handy in a pipe)
#   sudo NC_RAM_BUDGET_PCT=50 bash nc-audit.sh /var/www/nextcloud  # shared server (50% RAM ; default 100)
#   sudo NC_RAM_BUDGET_PCT=70 NC_INSTANCES="poolA:4,poolB:2,poolC:1" bash nc-audit.sh
#       # multi-instance: split the PHP budget across FPM pools by weight → a
#       # target pm.max_children per pool. Here NC_RAM_BUDGET_PCT is the TOTAL
#       # stack budget (all instances), not per-instance.
#   sudo NC_RAM_BUDGET_PCT=70 bash nc-audit.sh --tune-fpm
#       # interactive (terminal only): lists the FPM pools, asks a weight for
#       # each, prints the target pm.max_children + a reusable NC_INSTANCES line.
#   NC_PHP_SHARE_PCT=50  (default 60) — PHP's share of the stack budget (the rest
#       # covers DB + web + OS); lower it on DB-heavy servers. Range 20..90.
# ==============================================================================

# No `-e`: an audit must always run to the end, even if a sub-call fails on a
# missing/atypical layer. We keep -u (catch unset vars) but NOT pipefail:
# `pipefail` + `cmd | grep -q` is a trap — grep -q exits on the first match and
# closes the pipe, the upstream (e.g. `systemctl list-units`, lots of output)
# gets SIGPIPE (141), and pipefail turns that SUCCESSFUL match into a pipeline
# "failure" → flaky false negatives (the famous "PHP-FPM not detected" while it
# IS running). The audit never relies on pipefail (values use ${x:-default}).
set -u

# Script version (bumped by hand) — eases tracking: we know which version
# produced a report and we can spot an outdated script on a server.
AUDIT_VERSION="1.13"

# Current mode (report | json | push). In json/push mode the audit output is
# silent (stdout reserved for JSON) → header() prints progress on stderr.
AUDIT_MODE="report"

# --- Colors and formatting ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'

# Result counters
WARN_COUNT=0
CRIT_COUNT=0
OK_COUNT=0
INFO_COUNT=0

# Report file
REPORT_FILE="/tmp/nc-audit-$(hostname)-$(date +%Y%m%d-%H%M%S).txt"

# --- Utility functions ---

log_raw() {
    echo -e "$1"
    echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g' >> "$REPORT_FILE"
}

header() {
    # In json/push mode (silent output), give progress feedback on stderr —
    # without polluting the JSON on stdout.
    [ "${AUDIT_MODE:-report}" != "report" ] && printf '  … %s\n' "$1" >&2
    log_raw ""
    log_raw "${BOLD}${BLUE}═══════════════════════════════════════════════════════════${NC}"
    log_raw "${BOLD}${BLUE}  $1${NC}"
    log_raw "${BOLD}${BLUE}═══════════════════════════════════════════════════════════${NC}"
}

subheader() {
    log_raw ""
    log_raw "${BOLD}${CYAN}--- $1 ---${NC}"
}

ok() {
    log_raw "  ${GREEN}✔ OK${NC}    $1"
    ((OK_COUNT++)) || true
}

warn() {
    log_raw "  ${YELLOW}⚠ WARN${NC}  $1"
    ((WARN_COUNT++)) || true
}

crit() {
    log_raw "  ${RED}✘ CRIT${NC}  $1"
    ((CRIT_COUNT++)) || true
}

info() {
    log_raw "  ${CYAN}ℹ INFO${NC}  $1"
    ((INFO_COUNT++)) || true
}

recommend() {
    log_raw "  ${YELLOW}→ Recommendation:${NC} $1"
}

# Emits the multi-line output of an external tool: indented on screen AND in the
# report (ANSI codes stripped). Usage: some_command 2>&1 | emit
emit() {
    sed 's/\x1b\[[0-9;]*m//g' | while IFS= read -r line; do
        printf '   %s\n' "$line"
        printf '   %s\n' "$line" >> "$REPORT_FILE"
    done
}

# Convert a PHP value (128M, 1G, etc.) to MB
php_to_mb() {
    local val="$1"
    val=$(echo "$val" | tr -d ' ')
    if [[ "$val" =~ ^([0-9]+)[gG]$ ]]; then
        echo $(( ${BASH_REMATCH[1]} * 1024 ))
    elif [[ "$val" =~ ^([0-9]+)[mM]$ ]]; then
        echo "${BASH_REMATCH[1]}"
    elif [[ "$val" =~ ^([0-9]+)[kK]$ ]]; then
        echo $(( ${BASH_REMATCH[1]} / 1024 ))
    elif [[ "$val" =~ ^([0-9]+)$ ]]; then
        echo $(( val / 1024 / 1024 ))
    else
        echo "0"
    fi
}

# Read a value from a config file (ini-style)
get_ini_value() {
    local file="$1"
    local key="$2"
    local default="${3:-}"
    local val
    val=$(grep -E "^\s*${key}\s*=" "$file" 2>/dev/null | tail -1 | cut -d'=' -f2 | tr -d ' "'"'"'' | tr -d '\r')
    echo "${val:-$default}"
}

# Read an effective PHP value
get_php_ini_value() {
    local key="$1"
    local default="${2:-}"
    local val
    val=$(php -r "echo ini_get('$key');" 2>/dev/null)
    echo "${val:-$default}"
}

# Read a value from a PostgreSQL config
get_pg_setting() {
    local key="$1"
    local default="${2:-}"
    local val
    val=$(sudo -u postgres psql -t -c "SHOW $key;" 2>/dev/null | tr -d ' ')
    echo "${val:-$default}"
}

# Read a value from MariaDB
get_mysql_setting() {
    local key="$1"
    local default="${2:-}"
    local val
    val=$(mysql -N -e "SHOW VARIABLES LIKE '$key';" 2>/dev/null | awk '{print $2}')
    echo "${val:-$default}"
}

# Convert a PostgreSQL value to MB
pg_to_mb() {
    local val="$1"
    if [[ "$val" =~ ^([0-9]+)GB$ ]]; then
        echo $(( ${BASH_REMATCH[1]} * 1024 ))
    elif [[ "$val" =~ ^([0-9]+)MB$ ]]; then
        echo "${BASH_REMATCH[1]}"
    elif [[ "$val" =~ ^([0-9]+)kB$ ]]; then
        echo $(( ${BASH_REMATCH[1]} / 1024 ))
    elif [[ "$val" =~ ^([0-9]+)$ ]]; then
        # In 8kB pages by default
        echo $(( ${BASH_REMATCH[1]} * 8 / 1024 ))
    else
        echo "0"
    fi
}

# Determine the PHP SAPI that ACTUALLY serves Nextcloud (fpm > mod_php apache > cli)
# and its version. CLI values often differ (OPcache off in CLI, memory_limit…),
# so we read the right SAPI's config rather than via `php -r ini_get`.
PHP_AUDIT_VER=""
PHP_AUDIT_SAPI=""

detect_php_target() {
    PHP_AUDIT_VER=$(php -v 2>/dev/null | head -1 | awk '{print $2}' | grep -oP '^\d+\.\d+')
    if systemctl list-units --type=service 2>/dev/null | grep -q "php.*fpm" && [ -d "/etc/php/${PHP_AUDIT_VER}/fpm" ]; then
        PHP_AUDIT_SAPI="fpm"
    elif [ -d "/etc/php/${PHP_AUDIT_VER}/apache2" ] && apache2ctl -M 2>/dev/null | grep -qiE "php[0-9_]*_module|php_module"; then
        PHP_AUDIT_SAPI="apache2"
    else
        PHP_AUDIT_SAPI="cli"
    fi
}

# Read an EFFECTIVE ini directive from the target SAPI's files:
# php.ini + conf.d/*.ini (+ pool.d/*.conf for fpm, php_admin_value/php_value).
# Falls back to `php -r ini_get` (CLI) if the SAPI config can't be found.
get_effective_ini() {
    local key="$1"
    local default="${2:-}"
    local ver="${PHP_AUDIT_VER}" sapi="${PHP_AUDIT_SAPI}"
    local kre="${key//./\\.}"
    local val=""

    if [ -n "$ver" ] && [ -n "$sapi" ] && [ "$sapi" != "cli" ] && [ -d "/etc/php/${ver}/${sapi}" ]; then
        local files=()
        [ -f "/etc/php/${ver}/${sapi}/php.ini" ] && files+=("/etc/php/${ver}/${sapi}/php.ini")
        while IFS= read -r f; do files+=("$f"); done \
            < <(find "/etc/php/${ver}/${sapi}/conf.d/" -name '*.ini' 2>/dev/null | sort)
        if [ "$sapi" = "fpm" ]; then
            while IFS= read -r f; do files+=("$f"); done \
                < <(find "/etc/php/${ver}/fpm/pool.d/" -name '*.conf' 2>/dev/null | sort)
        fi
        local f line
        for f in "${files[@]}"; do
            # ini: "key = value" ; fpm pool: "php_value[key] = value" / "php_admin_value[key] = value"
            line=$(grep -E "^[[:space:]]*(php_(admin_)?value\[)?${kre}(\])?[[:space:]]*=" "$f" 2>/dev/null \
                   | grep -vE '^[[:space:]]*;' | tail -1)
            if [ -n "$line" ]; then
                val=$(echo "$line" | sed -E 's/^[^=]*=[[:space:]]*//; s/[[:space:]]*;.*$//; s/[[:space:]]*$//' | tr -d '"')
            fi
        done
    fi

    if [ -z "$val" ]; then
        val=$(php -r "echo ini_get('$key');" 2>/dev/null)
    fi
    echo "${val:-$default}"
}

# ==============================================================================
# SECTION 0 : Dependencies (required vs optional + how to install)
# ==============================================================================

audit_dependencies() {
    header "0. DEPENDENCIES"
    info "The script runs with the standard tools. The packages below enrich it — it works without them, but better with."

    subheader "Core tools (standard analyses)"
    local c
    for c in php awk sed grep find df free nproc; do
        if command -v "$c" >/dev/null 2>&1; then
            ok "${c} present"
        else
            crit "${c} missing — some analyses will be limited"
        fi
    done
    # util-linux: disk type detection (SSD/HDD)
    if command -v lsblk >/dev/null 2>&1 && command -v findmnt >/dev/null 2>&1; then
        ok "lsblk/findmnt present (SSD/HDD detection)"
    else
        warn "lsblk/findmnt missing — SSD/HDD detection disabled"
        recommend "Install: apt install util-linux"
    fi
    # timeout: anti-hang guard for the deep-analysis mode
    if ! command -v timeout >/dev/null 2>&1; then
        warn "timeout missing (coreutils) — the deep-analysis mode loses its anti-hang guard"
    fi

    subheader "Optional tools — deep analysis"
    info "Not required, but with them the audit goes FURTHER (recommendations based on real behavior):"
    local missing_apt=() missing_a2b=0 missing_sysstat=0
    if command -v mysqltuner >/dev/null 2>&1; then
        ok "mysqltuner present — runtime MySQL/MariaDB recommendations"
    else
        info "mysqltuner missing → without it: no runtime MySQL/MariaDB recommendations"
        missing_apt+=("mysqltuner")
    fi
    if command -v pt-variable-advisor >/dev/null 2>&1; then
        ok "percona-toolkit present — advice on MySQL variables"
    else
        info "percona-toolkit missing → without it: no advice on MySQL variables"
        missing_apt+=("percona-toolkit")
    fi
    if command -v sar >/dev/null 2>&1 || command -v iostat >/dev/null 2>&1; then
        ok "sysstat present — CPU/RAM history + disk I/O (sar/iostat)"
    else
        info "sysstat missing → without it: no CPU/RAM history nor disk I/O"
        missing_apt+=("sysstat"); missing_sysstat=1
    fi
    if command -v apache2buddy >/dev/null 2>&1 || [ -f /usr/local/bin/apache2buddy.pl ]; then
        ok "apache2buddy present — Apache sizing based on real RAM"
    else
        info "apache2buddy missing → without it: no Apache sizing (real RAM)"
        missing_a2b=1
    fi
    if command -v needrestart >/dev/null 2>&1; then
        ok "needrestart present — pending kernel reboot + services on stale libraries (section 8)"
    else
        info "needrestart missing → without it: no detection of services running on outdated libraries / kernel"
        missing_apt+=("needrestart")
    fi

    if [ "${#missing_apt[@]}" -gt 0 ] || [ "$missing_a2b" -eq 1 ]; then
        log_raw ""
        log_raw "  ${BOLD}${CYAN}➜ For more in-depth recommendations, install then re-run the audit:${NC}"
        [ "${#missing_apt[@]}" -gt 0 ] && log_raw "      ${GREEN}apt install ${missing_apt[*]}${NC}"
        [ "$missing_sysstat" -eq 1 ] && log_raw "      ${GREEN}systemctl enable --now sysstat${NC}   # enables history collection"
        if [ "$missing_a2b" -eq 1 ]; then
            log_raw "      ${CYAN}# apache2buddy (Perl, not packaged — the old \"apachebuddy\" is abandoned):${NC}"
            log_raw "      ${GREEN}sudo wget -qO /usr/local/bin/apache2buddy.pl https://raw.githubusercontent.com/richardforth/apache2buddy/master/apache2buddy.pl${NC}"
            log_raw "      ${GREEN}sudo chmod +x /usr/local/bin/apache2buddy.pl${NC}"
        fi
    else
        ok "All optional tools are present — maximum deep analysis."
    fi
}

# ==============================================================================
# SECTION 1 : Server capacity
# ==============================================================================

audit_server() {
    header "1. SERVER CAPACITY"

    # --- CPU ---
    subheader "CPU"
    local cpu_cores
    cpu_cores=$(nproc)
    local cpu_model
    cpu_model=$(grep -m1 "model name" /proc/cpuinfo | cut -d: -f2 | xargs)
    info "Processor: ${cpu_model}"
    info "Available cores: ${cpu_cores}"

    # --- RAM ---
    subheader "Memory"
    local total_ram_kb total_ram_mb available_ram_mb swap_total_mb
    total_ram_kb=$(grep MemTotal /proc/meminfo | awk '{print $2}')
    total_ram_mb=$((total_ram_kb / 1024))
    available_ram_mb=$(grep MemAvailable /proc/meminfo | awk '{print $2}')
    available_ram_mb=$((available_ram_mb / 1024))
    swap_total_mb=$(grep SwapTotal /proc/meminfo | awk '{print $2}')
    swap_total_mb=$((swap_total_mb / 1024))

    info "Total RAM: ${total_ram_mb} MB"
    info "Available RAM: ${available_ram_mb} MB"
    info "Swap: ${swap_total_mb} MB"

    if [ "$total_ram_mb" -lt 2048 ]; then
        crit "Less than 2 GB of RAM — Nextcloud may be slow"
        recommend "2 GB minimum recommended, ideally 4 GB+"
    elif [ "$total_ram_mb" -lt 4096 ]; then
        warn "Less than 4 GB of RAM — sufficient but limited"
    else
        ok "Sufficient RAM (${total_ram_mb} MB)"
    fi

    if [ "$swap_total_mb" -eq 0 ]; then
        warn "No swap configured"
        recommend "Add at least 1-2 GB of swap as a safety net"
    else
        ok "Swap configured (${swap_total_mb} MB)"
    fi

    # Swappiness: on a host with a database, we want to avoid swapping the cache.
    local swappiness
    swappiness=$(cat /proc/sys/vm/swappiness 2>/dev/null || echo "")
    if [ -n "$swappiness" ]; then
        info "vm.swappiness = ${swappiness}"
        if [ "$swappiness" -gt 30 ] 2>/dev/null; then
            warn "vm.swappiness high (${swappiness}) for an application/DB server"
            recommend "Lower to 10 (even 1 on a dedicated DB host): vm.swappiness=10 in /etc/sysctl.d/"
        elif [ "$swappiness" -gt 10 ] 2>/dev/null; then
            ok "vm.swappiness = ${swappiness} (fine ; ≤10 ideal on a DB host)"
        else
            ok "vm.swappiness = ${swappiness}"
        fi
    fi

    # --- Disk ---
    subheader "Storage"
    local disk_usage disk_percent
    disk_usage=$(df -h / | awk 'NR==2 {print $2 " total, " $3 " used, " $4 " free"}')
    disk_percent=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
    info "Partition /: ${disk_usage}"

    if [ "$disk_percent" -gt 90 ]; then
        crit "Disk used at ${disk_percent}% — almost full!"
        recommend "Free up space or grow the disk"
    elif [ "$disk_percent" -gt 80 ]; then
        warn "Disk used at ${disk_percent}%"
        recommend "Monitor disk space, plan for a resize"
    else
        ok "Disk space OK (${disk_percent}%)"
    fi

    # Disk type (SSD/HDD): drives the database I/O recommendations.
    # rotational = 0 → SSD/NVMe, 1 → mechanical disk.
    export SERVER_DISK_SSD="unknown"
    local root_src rota
    root_src=$(findmnt -no SOURCE / 2>/dev/null || true)
    if [ -n "$root_src" ]; then
        rota=$(lsblk -no ROTA "$root_src" 2>/dev/null | grep -oE '[01]' | head -1)
        if [ "$rota" = "0" ]; then
            export SERVER_DISK_SSD="true"
            ok "SSD/NVMe disk detected (non-rotational)"
        elif [ "$rota" = "1" ]; then
            export SERVER_DISK_SSD="false"
            info "Mechanical disk (HDD) detected"
        else
            info "Disk type undetermined"
        fi
    fi

    # Check the Nextcloud data directory if found
    if [ -n "${NC_DATA_DIR:-}" ] && [ -d "$NC_DATA_DIR" ]; then
        local data_disk data_percent
        data_disk=$(df -h "$NC_DATA_DIR" | awk 'NR==2 {print $2 " total, " $3 " used, " $4 " free"}')
        data_percent=$(df "$NC_DATA_DIR" | awk 'NR==2 {print $5}' | tr -d '%')
        info "Data partition (${NC_DATA_DIR}): ${data_disk}"
        if [ "$data_percent" -gt 85 ]; then
            warn "Data partition used at ${data_percent}%"
        fi
    fi

    # --- Allocation context: dedicated or shared? ---
    subheader "Memory allocation context"
    local budget_mb=$(( total_ram_mb * NC_RAM_BUDGET_PCT / 100 ))
    export SERVER_RAM_MB="$total_ram_mb"
    export SERVER_RAM_BUDGET_MB="$budget_mb"
    export SERVER_CPU_CORES="$cpu_cores"
    info "RAM budget for the Nextcloud stack: ${NC_RAM_BUDGET_PCT}% = ${budget_mb} MB (tunable via NC_RAM_BUDGET_PCT=…)"

    # Signs that the server ALSO hosts something else (→ memory recos = ceilings).
    local shared_signals=()

    # Other application databases (excluding system databases)?
    if mysql -N -e "SELECT 1" >/dev/null 2>&1; then
        local nb_mysql
        nb_mysql=$(mysql -N -e "SHOW DATABASES" 2>/dev/null \
            | grep -vE '^(information_schema|performance_schema|mysql|sys)$' | grep -c .)
        [ "${nb_mysql:-0}" -gt 1 ] 2>/dev/null && shared_signals+=("${nb_mysql} MySQL/MariaDB databases")
    fi
    if sudo -u postgres psql -tAc "SELECT 1" >/dev/null 2>&1; then
        local nb_pg
        nb_pg=$(sudo -u postgres psql -tAc \
            "SELECT count(*) FROM pg_database WHERE datistemplate=false AND datname<>'postgres'" 2>/dev/null || echo 0)
        [ "${nb_pg:-0}" -gt 1 ] 2>/dev/null && shared_signals+=("${nb_pg} PostgreSQL databases")
    fi

    # Multiple web vhosts? (we exclude the Debian default vhosts)
    # IMPORTANT: always print a number (even if the directory doesn't exist),
    # otherwise the $(( a + b )) arithmetic breaks on an empty value.
    count_vhosts() {
        local n=0
        if [ -d "$1" ]; then
            n=$(find "$1" -maxdepth 1 \( -type l -o -type f \) -printf '%f\n' 2>/dev/null \
                | grep -viE '^(000-default(\.conf)?|default(\.conf)?|default-ssl(\.conf)?)$' \
                | grep -c .)
        fi
        echo "${n:-0}"
    }
    local va vn nb_vhosts
    va=$(count_vhosts /etc/apache2/sites-enabled)
    vn=$(count_vhosts /etc/nginx/sites-enabled)
    nb_vhosts=$(( ${va:-0} + ${vn:-0} ))
    [ "$nb_vhosts" -gt 1 ] && shared_signals+=("${nb_vhosts} web vhosts (excluding defaults)")

    if [ "${#shared_signals[@]}" -gt 0 ]; then
        warn "Potentially shared server: ${shared_signals[*]}"
        if [ "$NC_RAM_BUDGET_PCT" -ge 100 ]; then
            recommend "The memory recos assume a DEDICATED server. Re-run with a suitable budget, e.g.: sudo NC_RAM_BUDGET_PCT=50 bash $0"
        else
            ok "Reduced budget already applied (${NC_RAM_BUDGET_PCT}%) — recos adjusted for sharing"
        fi
    else
        ok "No obvious sign of sharing (recommendations in dedicated mode)"
    fi
}

# ==============================================================================
# SECTION 2 : Nextcloud
# ==============================================================================

audit_nextcloud() {
    header "2. NEXTCLOUD"

    # Look for the Nextcloud installation
    local nc_path=""
    if [ -n "${NC_CUSTOM_PATH:-}" ] && [ -f "${NC_CUSTOM_PATH}/occ" ]; then
        nc_path="$NC_CUSTOM_PATH"
    fi

    if [ -z "$nc_path" ]; then
        for candidate in /var/www/nextcloud /var/www/html/nextcloud /var/www/*/nextcloud /srv/nextcloud; do
            if [ -f "${candidate}/occ" ] 2>/dev/null; then
                nc_path="$candidate"
                break
            fi
        done
    fi

    # Try via the Apache/Nginx config
    if [ -z "$nc_path" ]; then
        nc_path=$(grep -rl "occ" /var/www/ 2>/dev/null | head -1 | xargs dirname 2>/dev/null || true)
    fi

    if [ -z "$nc_path" ] || [ ! -f "${nc_path}/occ" ]; then
        warn "Nextcloud installation not found automatically"
        info "Specify the path as an argument: $0 /path/to/nextcloud  (or NC_PATH=… variable)"
        return
    fi

    info "Nextcloud path: ${nc_path}"

    local nc_user
    nc_user=$(stat -c '%U' "${nc_path}/occ")

    # Version
    subheader "Version and status"
    local nc_version
    nc_version=$(sudo -u "$nc_user" php "${nc_path}/occ" status --output=json 2>/dev/null | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo is_array($j)?($j["versionstring"]??"?"):"?";' 2>/dev/null || echo "unknown")
    info "Nextcloud version: ${nc_version}"

    # Data directory
    local nc_datadir
    nc_datadir=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get datadirectory 2>/dev/null || echo "")
    if [ -n "$nc_datadir" ]; then
        info "Data directory: ${nc_datadir}"
        export NC_DATA_DIR="$nc_datadir"
    fi

    # Database type used by Nextcloud → targets the relevant DB audit (on a shared
    # host, the other engine is audited but flagged "not used by NC").
    local nc_dbtype
    nc_dbtype=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get dbtype 2>/dev/null || echo "")
    if [ -n "$nc_dbtype" ]; then
        export NC_DBTYPE="$nc_dbtype"
        case "$nc_dbtype" in
            pgsql)   info "Database used by Nextcloud: PostgreSQL" ;;
            mysql)   info "Database used by Nextcloud: MySQL/MariaDB" ;;
            sqlite3) warn "Database used by Nextcloud: SQLite — discouraged in production"
                     recommend "Migrate to PostgreSQL or MariaDB (occ db:convert-type)" ;;
            *)       info "Database used by Nextcloud: ${nc_dbtype}" ;;
        esac
    fi

    # Cron
    subheader "Scheduled tasks (cron)"
    local cron_mode
    cron_mode=$(sudo -u "$nc_user" php "${nc_path}/occ" config:app:get core backgroundjobs_mode 2>/dev/null || echo "unknown")
    if [ "$cron_mode" = "cron" ]; then
        ok "System cron mode (recommended)"
    elif [ "$cron_mode" = "ajax" ]; then
        crit "AJAX mode — strongly discouraged in production"
        recommend "Switch to system cron: occ background:cron"
    elif [ "$cron_mode" = "webcron" ]; then
        warn "Webcron mode — system cron is preferable"
        recommend "Switch to system cron: occ background:cron"
    else
        warn "Cron mode: ${cron_mode}"
    fi

    # Check that the cron is running
    if crontab -u "$nc_user" -l 2>/dev/null | grep -q "cron.php"; then
        ok "Crontab configured for ${nc_user}"
    elif grep -rqs "cron\.php" /etc/cron.d/ /etc/crontab 2>/dev/null; then
        ok "Nextcloud cron configured (/etc/cron.d or /etc/crontab)"
    elif systemctl is-active --quiet nextcloud-cron.timer 2>/dev/null; then
        ok "Nextcloud cron via systemd timer"
    else
        warn "No Nextcloud crontab detected (cron mode active but entry not found)"
        recommend "Check the cron task: */5 * * * * php -f ${nc_path}/cron.php"
    fi

    # Memory cache
    subheader "Cache and performance"
    local memcache_local memcache_distributed memcache_locking
    memcache_local=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get memcache.local 2>/dev/null || echo "")
    memcache_distributed=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get memcache.distributed 2>/dev/null || echo "")
    memcache_locking=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get memcache.locking 2>/dev/null || echo "")

    if [[ "$memcache_local" == *"APCu"* ]]; then
        ok "memcache.local: APCu (recommended)"
    else
        warn "memcache.local not configured or not optimal (${memcache_local:-none})"
        recommend "Configure 'memcache.local' => '\\OC\\Memcache\\APCu' in config.php"
    fi

    if [[ "$memcache_locking" == *"Redis"* ]]; then
        ok "memcache.locking: Redis (recommended)"
    else
        warn "memcache.locking not configured on Redis (${memcache_locking:-none})"
        recommend "Install Redis and configure 'memcache.locking' => '\\OC\\Memcache\\Redis'"
    fi

    if [ -n "$memcache_distributed" ] && [[ "$memcache_distributed" == *"Redis"* ]]; then
        ok "memcache.distributed: Redis"
    else
        info "memcache.distributed not configured (optional on a single-server instance)"
    fi

    # Redis: check that it REALLY responds (beyond the NC config).
    # Socket-aware: NC often uses a unix socket → a TCP ping would wrongly fail.
    subheader "Redis"
    local nc_uses_redis="no"
    if [[ "$memcache_local" == *"Redis"* || "$memcache_locking" == *"Redis"* || "$memcache_distributed" == *"Redis"* ]]; then
        nc_uses_redis="yes"
    fi
    if ! command -v redis-cli >/dev/null 2>&1; then
        if [ "$nc_uses_redis" = "yes" ]; then
            crit "Nextcloud is configured on Redis but redis-cli/Redis is not found"
            recommend "Install Redis: apt install redis-server"
        else
            info "Redis not installed (Nextcloud is not configured for Redis)"
        fi
    else
        local r_host r_port ping_out
        r_host=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get redis host 2>/dev/null || echo "")
        r_port=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get redis port 2>/dev/null || echo "")
        if [[ "$r_host" == /* ]]; then
            ping_out=$(redis-cli -s "$r_host" ping 2>/dev/null)
        elif [ -n "$r_host" ]; then
            ping_out=$(redis-cli -h "$r_host" -p "${r_port:-6379}" ping 2>/dev/null)
        else
            ping_out=$(redis-cli ping 2>/dev/null)
        fi
        if echo "$ping_out" | grep -q "PONG"; then
            ok "Redis responds (PONG)"
        elif systemctl is-active --quiet redis-server 2>/dev/null || systemctl is-active --quiet redis 2>/dev/null; then
            warn "Redis active but does not respond to ping (host/port/socket or auth?)"
            recommend "Check the Redis connection used by Nextcloud (the 'redis' key in config.php)"
        elif [ "$nc_uses_redis" = "yes" ]; then
            crit "Nextcloud configured on Redis but Redis does not respond / is inactive"
            recommend "Start Redis: systemctl start redis-server"
        else
            info "Redis installed but inactive (not used by Nextcloud)"
        fi
    fi

    # Check for missing indices
    local missing_indices
    missing_indices=$(sudo -u "$nc_user" php "${nc_path}/occ" db:add-missing-indices --dry-run 2>&1 || true)
    if echo "$missing_indices" | grep -qi "missing"; then
        warn "Missing database indices detected"
        recommend "Run: sudo -u ${nc_user} php ${nc_path}/occ db:add-missing-indices"
    else
        ok "Database indices OK"
    fi

    # Missing primary keys — READ-ONLY via --dry-run.
    # NB: db:add-missing-columns has no reliable dry-run and WOULD MODIFY the database,
    # so we don't run it here (the audit stays strictly non-destructive).
    local missing_pk
    missing_pk=$(sudo -u "$nc_user" php "${nc_path}/occ" db:add-missing-primary-keys --dry-run 2>&1 || true)
    if echo "$missing_pk" | grep -qi "missing"; then
        warn "Missing database primary keys"
        recommend "Run: sudo -u ${nc_user} php ${nc_path}/occ db:add-missing-primary-keys"
    else
        ok "Database primary keys OK"
    fi

    # Default phone region
    local phone_region
    phone_region=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get default_phone_region 2>/dev/null || echo "")
    if [ -z "$phone_region" ]; then
        warn "default_phone_region not configured"
        recommend "Add 'default_phone_region' => 'FR' in config.php"
    else
        ok "default_phone_region: ${phone_region}"
    fi

    # Maintenance window
    local maint_window
    maint_window=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get maintenance_window_start 2>/dev/null || echo "")
    if [ -z "$maint_window" ]; then
        warn "maintenance_window_start not configured"
        recommend "Add 'maintenance_window_start' => 1 (1am UTC) in config.php"
    else
        ok "Maintenance window: ${maint_window}h UTC"
    fi

    # Nextcloud logs: only if log_type=file, and at the REAL path ('logfile' key
    # or datadirectory/nextcloud.log) — no hard-coded path.
    subheader "Nextcloud logs"
    local log_type log_file err_count
    log_type=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get log_type 2>/dev/null || echo "file")
    if [ "${log_type:-file}" != "file" ]; then
        info "Logs sent to \"${log_type}\" (not a file) — scan not applicable"
    else
        log_file=$(sudo -u "$nc_user" php "${nc_path}/occ" config:system:get logfile 2>/dev/null || echo "")
        [ -z "$log_file" ] && [ -n "${NC_DATA_DIR:-}" ] && log_file="${NC_DATA_DIR}/nextcloud.log"
        if [ -n "$log_file" ] && [ -f "$log_file" ]; then
            # NC logs in JSON: level 3=error, 4=fatal (text fallback ERROR/Fatal).
            # Bounded to the last 2000 lines (recency + performance).
            err_count=$(tail -n 2000 "$log_file" 2>/dev/null | grep -cE '"level":(3|4)|\bERROR\b|\bFatal\b' || true)
            err_count=${err_count:-0}
            if [ "$err_count" -gt 0 ]; then
                warn "${err_count} error(s)/fatal(s) in the last 2000 log lines"
                recommend "Inspect: tail -n 50 ${log_file}"
            else
                ok "No recent errors in ${log_file}"
            fi
        else
            info "Nextcloud log file not found (${log_file:-not set})"
        fi
    fi
}

# ==============================================================================
# SECTION 3 : PHP / PHP-FPM
# ==============================================================================

audit_php() {
    header "3. PHP / PHP-FPM"

    if ! command -v php &>/dev/null; then
        crit "PHP not found"
        return
    fi

    local php_version
    php_version=$(php -v 2>/dev/null | head -1 | awk '{print $2}' || echo "unknown")
    local php_major_minor
    php_major_minor=$(echo "$php_version" | grep -oP '^\d+\.\d+')
    info "PHP version: ${php_version}"

    # Target the config of the SAPI that actually serves Nextcloud (FPM/apache), not the CLI.
    detect_php_target
    if [ "$PHP_AUDIT_SAPI" = "cli" ]; then
        info "PHP config read: CLI (neither PHP-FPM nor mod_php detected — values may differ from the web)"
    else
        info "PHP config read: SAPI ${PHP_AUDIT_SAPI} (the one serving Nextcloud)"
    fi

    # Check that the version is supported by Nextcloud
    subheader "PHP compatibility"
    local php_major
    php_major=$(echo "$php_major_minor" | cut -d. -f1)
    local php_minor
    php_minor=$(echo "$php_major_minor" | cut -d. -f2)

    if [ "$php_major" -ge 8 ] && [ "$php_minor" -ge 1 ]; then
        ok "PHP ${php_major_minor} supported by Nextcloud"
    else
        crit "PHP ${php_major_minor} — version too old"
        recommend "Update to PHP 8.1 minimum (8.3+ recommended)"
    fi

    # --- Key PHP settings ---
    subheader "PHP settings"

    local memory_limit upload_max post_max max_exec opcache_enable opcache_mem opcache_strings opcache_max_files opcache_revalidate
    memory_limit=$(get_effective_ini "memory_limit" "128M")
    upload_max=$(get_effective_ini "upload_max_filesize" "2M")
    post_max=$(get_effective_ini "post_max_size" "8M")
    max_exec=$(get_effective_ini "max_execution_time" "30")
    opcache_enable=$(get_effective_ini "opcache.enable" "0")
    opcache_mem=$(get_effective_ini "opcache.memory_consumption" "128")
    opcache_strings=$(get_effective_ini "opcache.interned_strings_buffer" "8")
    opcache_max_files=$(get_effective_ini "opcache.max_accelerated_files" "10000")
    opcache_revalidate=$(get_effective_ini "opcache.revalidate_freq" "2")
    local opcache_jit
    opcache_jit=$(get_effective_ini "opcache.jit" "off")
    local opcache_jit_buffer
    opcache_jit_buffer=$(get_effective_ini "opcache.jit_buffer_size" "0")

    # memory_limit
    local mem_mb
    mem_mb=$(php_to_mb "$memory_limit")
    info "memory_limit = ${memory_limit}"
    if [ "$mem_mb" -lt 512 ]; then
        crit "memory_limit too low (${memory_limit})"
        recommend "Set memory_limit to 512M minimum (1G recommended if RAM > 4 GB)"
    elif [ "$mem_mb" -lt 1024 ] && [ "${SERVER_RAM_MB:-0}" -ge 4096 ]; then
        warn "memory_limit could be increased (${memory_limit})"
        recommend "Set to 1G given the available RAM"
    else
        ok "memory_limit = ${memory_limit}"
    fi

    # upload_max_filesize
    local upload_mb
    upload_mb=$(php_to_mb "$upload_max")
    info "upload_max_filesize = ${upload_max}"
    if [ "$upload_mb" -lt 512 ]; then
        warn "upload_max_filesize low (${upload_max})"
        recommend "Set to 512M or more to allow uploading large files"
    else
        ok "upload_max_filesize = ${upload_max}"
    fi

    # post_max_size
    local post_mb
    post_mb=$(php_to_mb "$post_max")
    if [ "$post_mb" -lt "$upload_mb" ]; then
        crit "post_max_size (${post_max}) < upload_max_filesize (${upload_max})"
        recommend "post_max_size must be >= upload_max_filesize"
    else
        ok "post_max_size = ${post_max}"
    fi

    # max_execution_time
    info "max_execution_time = ${max_exec}s"
    if [ "$max_exec" -lt 300 ] && [ "$max_exec" -ne 0 ]; then
        warn "max_execution_time short (${max_exec}s)"
        recommend "Set to 300 or 3600 for long operations (migration, file scan)"
    else
        ok "max_execution_time = ${max_exec}s"
    fi

    # OPcache
    subheader "OPcache"
    if [[ "$opcache_enable" =~ ^(1|[Oo]n|[Tt]rue)$ ]]; then
        ok "OPcache enabled"
    else
        crit "OPcache disabled (SAPI ${PHP_AUDIT_SAPI})"
        recommend "Enable opcache.enable = 1, essential for Nextcloud performance"
    fi

    info "opcache.memory_consumption = ${opcache_mem} MB"
    if [ "$opcache_mem" -lt 128 ]; then
        warn "opcache.memory_consumption low (${opcache_mem} MB)"
        recommend "Set to 128 MB minimum"
    else
        ok "opcache.memory_consumption = ${opcache_mem} MB"
    fi

    info "opcache.interned_strings_buffer = ${opcache_strings} MB"
    if [ "$opcache_strings" -lt 16 ]; then
        warn "opcache.interned_strings_buffer low (${opcache_strings} MB)"
        recommend "Set to 16 MB minimum"
    fi

    info "opcache.max_accelerated_files = ${opcache_max_files}"
    if [ "$opcache_max_files" -lt 10000 ]; then
        warn "opcache.max_accelerated_files too low (${opcache_max_files})"
        recommend "Set to 10000 minimum (Nextcloud has thousands of PHP files)"
    else
        ok "opcache.max_accelerated_files = ${opcache_max_files}"
    fi

    info "opcache.revalidate_freq = ${opcache_revalidate}"
    if [ "$opcache_revalidate" -gt 60 ]; then
        info "opcache.revalidate_freq high — watch out for frequent code updates"
    fi

    # OPcache RUNTIME (hit-rate, real memory) — beyond the plain config.
    # NB: via CLI we read the CLI SAPI's cache; the FPM cache may differ
    # (but it's the only reliable access without an FPM status endpoint).
    local oc_rt
    oc_rt=$(php -r 'if(function_exists("opcache_get_status")){$s=@opcache_get_status(false); if(is_array($s)&&!empty($s["opcache_enabled"])){$h=$s["opcache_statistics"]["opcache_hit_rate"]??0; $um=round(($s["memory_usage"]["used_memory"]??0)/1048576,1); $fm=round(($s["memory_usage"]["free_memory"]??0)/1048576,1); $k=$s["opcache_statistics"]["num_cached_keys"]??0; $mk=$s["opcache_statistics"]["max_cached_keys"]??0; printf("%.1f|%s|%s|%s|%s",$h,$um,$fm,$k,$mk);}}' 2>/dev/null)
    if [ -n "$oc_rt" ]; then
        local oc_hit oc_used oc_free oc_keys oc_maxkeys
        IFS='|' read -r oc_hit oc_used oc_free oc_keys oc_maxkeys <<< "$oc_rt"
        info "OPcache runtime (CLI): hit-rate ${oc_hit}%, ${oc_used} MB used / ${oc_free} MB free, ${oc_keys}/${oc_maxkeys} keys"
        if awk "BEGIN{exit !(${oc_hit:-100} < 95)}" 2>/dev/null; then
            warn "OPcache hit-rate low (${oc_hit}%)"
            recommend "Increase opcache.memory_consumption / opcache.max_accelerated_files (also check the FPM side)"
        fi
        if awk "BEGIN{exit !(${oc_free:-100} < 16)}" 2>/dev/null; then
            warn "OPcache nearly saturated (${oc_free} MB free)"
            recommend "Increase opcache.memory_consumption"
        fi
        if [ "${oc_maxkeys:-0}" -gt 0 ] 2>/dev/null && [ "${oc_keys:-0}" -ge "$(( oc_maxkeys * 95 / 100 ))" ] 2>/dev/null; then
            warn "OPcache: almost all keys used (${oc_keys}/${oc_maxkeys})"
            recommend "Increase opcache.max_accelerated_files"
        fi
    else
        info "OPcache runtime not readable in CLI (cache specific to FPM) — see Nextcloud → Administration → System information, or the cachetool tool"
    fi

    # --- PHP-FPM ---
    subheader "PHP-FPM"
    if ! systemctl list-units --type=service 2>/dev/null | grep -q "php.*fpm"; then
        info "PHP-FPM not detected (mod_php may be used instead)"
    else
        local fpm_conf
        fpm_conf="/etc/php/${php_major_minor}/fpm/pool.d/www.conf"
        if [ ! -f "$fpm_conf" ]; then
            # Look for another pool
            fpm_conf=$(find /etc/php/ -name "*.conf" -path "*/pool.d/*" 2>/dev/null | head -1)
        fi

        if [ -z "$fpm_conf" ] || [ ! -f "$fpm_conf" ]; then
            warn "PHP-FPM configuration not found"
        else
            info "Pool config: ${fpm_conf}"

            local pm pm_max pm_start pm_min_spare pm_max_spare pm_max_requests
            pm=$(get_ini_value "$fpm_conf" "pm" "dynamic")
            pm_max=$(get_ini_value "$fpm_conf" "pm.max_children" "5")
            pm_start=$(get_ini_value "$fpm_conf" "pm.start_servers" "2")
            pm_min_spare=$(get_ini_value "$fpm_conf" "pm.min_spare_servers" "1")
            pm_max_spare=$(get_ini_value "$fpm_conf" "pm.max_spare_servers" "3")
            pm_max_requests=$(get_ini_value "$fpm_conf" "pm.max_requests" "0")

            info "pm = ${pm}"
            info "pm.max_children = ${pm_max}"
            info "pm.start_servers = ${pm_start}"
            info "pm.min_spare_servers = ${pm_min_spare}"
            info "pm.max_spare_servers = ${pm_max_spare}"
            info "pm.max_requests = ${pm_max_requests}"

            # Compute the recommended max_children
            # Formula: (RAM available for PHP) / (average memory per process)
            # We estimate ~50MB per PHP-FPM process for Nextcloud
            local php_process_mb=50
            local ram_for_php=$(( ${SERVER_RAM_BUDGET_MB:-2048} * ${PHP_BUDGET_SHARE_PCT:-60} / 100 ))  # PHP share of the RAM budget
            local recommended_max_children=$(( ram_for_php / php_process_mb ))

            # Clamp to reasonable values
            if [ "$recommended_max_children" -gt 512 ]; then
                recommended_max_children=512
            fi
            if [ "$recommended_max_children" -lt 5 ]; then
                recommended_max_children=5
            fi

            if [ "$pm" = "dynamic" ]; then
                ok "Dynamic mode (suitable for most cases)"
            elif [ "$pm" = "static" ]; then
                info "Static mode — fits dedicated Nextcloud servers"
            elif [ "$pm" = "ondemand" ]; then
                warn "Ondemand mode — may add latency"
                recommend "Switch to dynamic for better performance"
            fi

            # pm.max_children is a concurrency LIMIT (max number of PHP processes),
            # not a target: recommended_max_children = CEILING that the RAM budget
            # can hold (~${php_process_mb} MB/process). Above it → OOM risk under
            # load; below it → headroom (only raise if saturation).
            if [ "$pm_max" -gt "$recommended_max_children" ]; then
                warn "pm.max_children (${pm_max}) exceeds RAM capacity (~${recommended_max_children} max, ~${php_process_mb} MB/process)"
                recommend "Reduce to ~${recommended_max_children} to avoid OOM under peak load"
            else
                ok "pm.max_children = ${pm_max} (under the RAM ceiling ~${recommended_max_children} ; concurrency limit, raise if saturation)"
            fi

            if [ "$pm_max_requests" = "0" ]; then
                warn "pm.max_requests = 0 (unlimited) — memory-leak risk"
                recommend "Set pm.max_requests = 500 to recycle processes"
            else
                ok "pm.max_requests = ${pm_max_requests}"
            fi
        fi

        # --- Multi-pool memory accounting (all instances on this host) ---
        # Several Nextcloud instances usually run one FPM pool each. The PHP
        # memory that matters is the SUM over the DISTINCT instances of
        # pm.max_children × ~per-process MB. We dedupe by pool name (basename):
        # the SAME pool defined under two PHP versions (a classic leftover after
        # a PHP upgrade) must not be double-counted — only the obsolete version's
        # php-fpm wastes some idle RAM, but the instance can only saturate once.
        local fpm_pool_count=0 pool_f pc pool_name
        local -A pool_children=()   # name -> max pm.max_children seen across versions
        while IFS= read -r pool_f; do
            [ -f "$pool_f" ] || continue
            pc=$(get_ini_value "$pool_f" "pm.max_children" "0")
            [[ "$pc" =~ ^[0-9]+$ ]] || pc=0
            pool_name=$(basename "$pool_f" .conf)
            [ "${pool_children[$pool_name]:-0}" -lt "$pc" ] && pool_children[$pool_name]="$pc"
            fpm_pool_count=$(( fpm_pool_count + 1 ))
        done < <(find /etc/php/ -path "*/pool.d/*.conf" 2>/dev/null | sort)

        local fpm_total_children=0 fpm_uniq_count=0
        for pool_name in "${!pool_children[@]}"; do
            fpm_total_children=$(( fpm_total_children + pool_children[$pool_name] ))
            fpm_uniq_count=$(( fpm_uniq_count + 1 ))
        done
        export FPM_TOTAL_MB=$(( fpm_total_children * 50 ))

        if [ "$fpm_uniq_count" -gt 1 ]; then
            info "PHP-FPM: ${fpm_pool_count} pool file(s), ${fpm_uniq_count} distinct instance(s) (Σ pm.max_children = ${fpm_total_children} → ~${FPM_TOTAL_MB} MB at ~50 MB/process)"
            [ -z "${NC_INSTANCES:-}" ] && recommend "Multi-instance host: size each pool's pm.max_children by instance weight, or pass NC_INSTANCES=\"pool:weight,...\" for a prescriptive split. The SUM must fit the PHP share of the RAM budget."
        fi

        # Same pool name under several PHP versions = leftover after a PHP upgrade.
        if [ "$fpm_pool_count" -gt "$fpm_uniq_count" ]; then
            warn "${fpm_pool_count} pool files for only ${fpm_uniq_count} instances — duplicate pools across PHP versions (leftover from a PHP upgrade)"
            recommend "Each running php-fpm version still spawns its pools (idle RAM wasted). Remove the obsolete version's pools (/etc/php/<old>/fpm/pool.d/) and disable that php-fpm if unused."
        fi

        # --- NC_INSTANCES: prescriptive per-pool max_children split ---
        # When the operator declares instance weights, NC_RAM_BUDGET_PCT is read
        # as the TOTAL stack budget (all instances), and the PHP share is split
        # across pools by weight → a target pm.max_children per pool.
        if [ -n "${NC_INSTANCES:-}" ]; then
            subheader "PHP-FPM pools — budget split (NC_INSTANCES)"
            info "NC_RAM_BUDGET_PCT read as the TOTAL stack budget here (all instances)."
            local php_share=$(( ${SERVER_RAM_BUDGET_MB:-2048} * ${PHP_BUDGET_SHARE_PCT:-60} / 100 ))
            local _specs spec name w total_w=0
            IFS=',' read -ra _specs <<< "$NC_INSTANCES"
            for spec in "${_specs[@]}"; do
                w="${spec##*:}"; [[ "$w" =~ ^[0-9]+$ ]] || w=0
                total_w=$(( total_w + w ))
            done
            if [ "$total_w" -le 0 ]; then
                warn "NC_INSTANCES has no valid weights (expected \"name:weight,name:weight,...\")"
            else
                info "PHP share of budget: ${php_share} MB ; total weight: ${total_w}"
                local sum_target=0 target curr pool_path
                for spec in "${_specs[@]}"; do
                    name="${spec%%:*}"; w="${spec##*:}"
                    { [[ "$w" =~ ^[0-9]+$ ]] && [ "$w" -gt 0 ]; } || { warn "Invalid NC_INSTANCES item '${spec}' (expected name:weight)"; continue; }
                    target=$(( php_share * w / total_w / 50 ))
                    [ "$target" -lt 1 ] && target=1
                    sum_target=$(( sum_target + target ))
                    pool_path=$(find /etc/php/ -path "*/pool.d/${name}.conf" 2>/dev/null | head -1)
                    if [ -n "$pool_path" ]; then
                        curr=$(get_ini_value "$pool_path" "pm.max_children" "?")
                        info "${name} (weight ${w}/${total_w}) → target pm.max_children ~${target} (current ${curr})"
                    else
                        warn "${name}: no pool file /etc/php/*/fpm/pool.d/${name}.conf found"
                    fi
                done
                info "Σ target children = ${sum_target} (~$(( sum_target * 50 )) MB, within the ${php_share} MB PHP share)"
                recommend "Set each pool's pm.max_children to its target; keep the sum within the PHP budget."
            fi
        fi
    fi

    # Check the required PHP modules
    subheader "Required PHP modules"
    local required_modules=("curl" "gd" "mbstring" "xml" "zip" "intl" "bcmath" "gmp" "imagick" "apcu" "redis")
    for mod in "${required_modules[@]}"; do
        if php -m 2>/dev/null | grep -qi "^${mod}$"; then
            ok "Module ${mod} present"
        else
            if [ "$mod" = "apcu" ] || [ "$mod" = "redis" ] || [ "$mod" = "imagick" ]; then
                warn "Module ${mod} missing (recommended)"
                recommend "Install php${php_major_minor}-${mod} or php-${mod}"
            else
                crit "Module ${mod} missing (required)"
                recommend "Install php${php_major_minor}-${mod}"
            fi
        fi
    done
}

# ==============================================================================
# SECTION 4 : Apache
# ==============================================================================

audit_apache() {
    header "4. APACHE"

    if ! command -v apache2 &>/dev/null && ! command -v httpd &>/dev/null; then
        info "Apache not installed — section skipped"
        return
    fi

    if ! systemctl is-active --quiet apache2 2>/dev/null; then
        info "Apache not active — section skipped"
        return
    fi

    local apache_version
    apache_version=$(apache2 -v 2>/dev/null | head -1)
    info "${apache_version}"

    # MPM
    subheader "MPM (Multi-Processing Module)"
    local mpm
    # grep -oE 'mpm_(prefork|worker|event)' : without it we'd capture "mpm_prefork_module"
    # (the _module suffix) and the comparison below fails → prefork not flagged.
    mpm=$(apache2ctl -M 2>/dev/null | grep -oE 'mpm_(prefork|worker|event)' | head -1 || echo "unknown")
    info "Active MPM: ${mpm}"

    if [ "$mpm" = "mpm_event" ]; then
        ok "MPM event (recommended for performance)"
    elif [ "$mpm" = "mpm_worker" ]; then
        ok "MPM worker (acceptable)"
    elif [ "$mpm" = "mpm_prefork" ]; then
        warn "MPM prefork — less performant"
        recommend "Switch to mpm_event if possible (requires php-fpm instead of mod_php)"
    fi

    # MaxRequestWorkers vs server capacity
    local max_workers
    max_workers=$(grep -rE "^\s*MaxRequestWorkers" /etc/apache2/ 2>/dev/null | head -1 | awk '{print $2}')
    if [ -n "$max_workers" ]; then
        info "MaxRequestWorkers = ${max_workers}"
        # Per-worker memory depends on the model, NOT a flat figure:
        #  - mpm_prefork + mod_php: each worker is a full Apache process WITH PHP
        #    embedded → heavy (~40-80 MB). MaxRequestWorkers × that IS the real
        #    PHP memory, so flag it against the budget.
        #  - mpm_event/worker + PHP-FPM: each worker is a light proxy thread
        #    (~a few MB); the real PHP memory lives in the FPM pools (accounted
        #    separately). A high MaxRequestWorkers is fine here, so we must not
        #    bill it the PHP memory again (that was a false positive + double count).
        local estimated_worker_mb=40
        if { [ "$mpm" = "mpm_event" ] || [ "$mpm" = "mpm_worker" ]; } && [ "${PHP_AUDIT_SAPI:-}" = "fpm" ]; then
            estimated_worker_mb=8
        fi
        local max_worker_ram_mb=$(( max_workers * estimated_worker_mb ))
        export APACHE_MEM_MB="$max_worker_ram_mb"
        if [ "$estimated_worker_mb" -le 8 ]; then
            ok "MaxRequestWorkers = ${max_workers} (light proxy threads, ~${max_worker_ram_mb} MB; real PHP memory is in the FPM pools)"
        elif [ "$max_worker_ram_mb" -gt "${SERVER_RAM_BUDGET_MB:-2048}" ]; then
            warn "MaxRequestWorkers (${max_workers}) could saturate RAM under peak load (~${max_worker_ram_mb} MB, mod_php embeds PHP per process)"
            recommend "Reduce MaxRequestWorkers, or move to mpm_event + PHP-FPM, vs the ${SERVER_RAM_BUDGET_MB:-?} MB budget"
        else
            ok "MaxRequestWorkers (${max_workers}) consistent with the RAM budget (~${max_worker_ram_mb} MB)"
        fi
    fi

    # Important modules
    subheader "Apache modules"
    local important_modules=("rewrite" "headers" "env" "dir" "mime" "ssl")
    for mod in "${important_modules[@]}"; do
        if apache2ctl -M 2>/dev/null | grep -qi "${mod}_module"; then
            ok "Module ${mod} enabled"
        else
            warn "Module ${mod} not enabled"
            recommend "Enable with: a2enmod ${mod}"
        fi
    done

    # HTTP/2
    if apache2ctl -M 2>/dev/null | grep -qi "http2_module"; then
        ok "HTTP/2 available"
    else
        warn "HTTP/2 not enabled"
        recommend "a2enmod http2 and add 'Protocols h2 http/1.1' in the SSL vhosts"
    fi

    # Security headers
    subheader "Apache security"
    local apache_conf="/etc/apache2/conf-available/security.conf"
    if [ -f "$apache_conf" ]; then
        local server_tokens
        server_tokens=$(grep -E "^ServerTokens" "$apache_conf" 2>/dev/null | awk '{print $2}')
        if [ "$server_tokens" = "Prod" ] || [ "$server_tokens" = "ProductOnly" ]; then
            ok "ServerTokens = ${server_tokens} (hidden)"
        else
            warn "ServerTokens = ${server_tokens:-not set}"
            recommend "Set ServerTokens Prod to hide the version"
        fi

        local server_sig
        server_sig=$(grep -E "^ServerSignature" "$apache_conf" 2>/dev/null | awk '{print $2}')
        if [ "$server_sig" = "Off" ]; then
            ok "ServerSignature Off"
        else
            warn "ServerSignature not disabled"
            recommend "Set ServerSignature Off"
        fi
    fi

    # Check compression
    if apache2ctl -M 2>/dev/null | grep -qi "deflate_module"; then
        ok "Compression (mod_deflate) enabled"
    else
        warn "Compression not enabled"
        recommend "a2enmod deflate to enable gzip compression"
    fi

    # KeepAlive
    local keepalive
    keepalive=$(grep -rE "^KeepAlive " /etc/apache2/ 2>/dev/null | head -1 | awk '{print $2}')
    if [ "$keepalive" = "On" ] || [ -z "$keepalive" ]; then
        ok "KeepAlive enabled"
    else
        warn "KeepAlive disabled"
        recommend "Enable KeepAlive On for better performance"
    fi
}

# ==============================================================================
# SECTION 5 : Nginx
# ==============================================================================

audit_nginx() {
    header "5. NGINX"

    if ! command -v nginx &>/dev/null; then
        info "Nginx not installed — section skipped"
        return
    fi

    if ! systemctl is-active --quiet nginx 2>/dev/null; then
        info "Nginx not active — section skipped"
        return
    fi

    local nginx_version
    nginx_version=$(nginx -v 2>&1)
    info "${nginx_version}"

    local nginx_conf="/etc/nginx/nginx.conf"

    # Worker processes
    subheader "Workers"
    local workers
    workers=$(grep -E "^\s*worker_processes" "$nginx_conf" 2>/dev/null | awk '{print $2}' | tr -d ';')
    info "worker_processes = ${workers:-not set}"

    if [ "$workers" = "auto" ]; then
        ok "worker_processes auto (matches the number of cores)"
    elif [ -n "$workers" ]; then
        if [ "$workers" -lt "${SERVER_CPU_CORES:-1}" ] 2>/dev/null; then
            warn "worker_processes (${workers}) < number of cores (${SERVER_CPU_CORES:-?})"
            recommend "Use worker_processes auto"
        else
            ok "worker_processes = ${workers}"
        fi
    fi

    # Worker connections
    local worker_conn
    worker_conn=$(grep -E "^\s*worker_connections" "$nginx_conf" 2>/dev/null | awk '{print $2}' | tr -d ';')
    info "worker_connections = ${worker_conn:-not set}"
    if [ -n "$worker_conn" ] && [ "$worker_conn" -lt 1024 ]; then
        warn "worker_connections low (${worker_conn})"
        recommend "Increase to 1024 minimum"
    fi

    # Gzip
    subheader "Compression"
    if grep -qE "^\s*gzip\s+on" "$nginx_conf" 2>/dev/null; then
        ok "Gzip enabled"
    else
        warn "Gzip not enabled in nginx.conf"
        recommend "Enable gzip on and configure gzip_types"
    fi

    # Security
    subheader "Nginx security"
    if grep -rqE "server_tokens\s+off" /etc/nginx/ 2>/dev/null; then
        ok "server_tokens off (version hidden)"
    else
        warn "server_tokens not disabled"
        recommend "Add server_tokens off in the http block"
    fi

    # SSL
    subheader "SSL/TLS"
    if grep -rqE "ssl_protocols" /etc/nginx/ 2>/dev/null; then
        local ssl_protocols
        ssl_protocols=$(grep -rE "ssl_protocols" /etc/nginx/ 2>/dev/null | head -1 | awk '{$1=""; print $0}' | tr -d ';')
        info "ssl_protocols:${ssl_protocols}"
        if echo "$ssl_protocols" | grep -q "TLSv1 " || echo "$ssl_protocols" | grep -q "TLSv1.0"; then
            crit "TLSv1.0 still enabled — vulnerable"
            recommend "Use only TLSv1.2 TLSv1.3"
        elif echo "$ssl_protocols" | grep -q "TLSv1.1"; then
            warn "TLSv1.1 still enabled — obsolete"
            recommend "Use only TLSv1.2 TLSv1.3"
        else
            ok "Modern SSL protocols"
        fi
    fi

    # HTTP/2: "listen ... http2" (old) or "http2 on;" (nginx >= 1.25).
    # We do NOT settle for a listen 443 ssl (that doesn't prove HTTP/2).
    if grep -rqE "listen[^;]*http2|^[[:space:]]*http2[[:space:]]+on" /etc/nginx/ 2>/dev/null; then
        ok "HTTP/2 enabled"
    else
        warn "HTTP/2 not detected"
        recommend "Enable HTTP/2 (the 'http2 on;' directive on TLS vhosts)"
    fi

    # Buffers and timeouts
    subheader "Buffers and limits"
    local client_max
    client_max=$(grep -rE "client_max_body_size" /etc/nginx/ 2>/dev/null | head -1 | awk '{print $2}' | tr -d ';')
    if [ -n "$client_max" ]; then
        info "client_max_body_size = ${client_max}"
        local max_body_mb
        max_body_mb=$(php_to_mb "$client_max")
        if [ "$max_body_mb" -lt 512 ]; then
            warn "client_max_body_size low (${client_max})"
            recommend "Increase to 512M or more for Nextcloud"
        else
            ok "client_max_body_size = ${client_max}"
        fi
    else
        warn "client_max_body_size not set (default: 1M)"
        recommend "Set client_max_body_size 512M minimum"
    fi
}

# ==============================================================================
# SECTION 6 : PostgreSQL
# ==============================================================================

audit_postgresql() {
    header "6. POSTGRESQL"

    if ! systemctl is-active --quiet postgresql 2>/dev/null; then
        info "PostgreSQL not active — section skipped"
        return
    fi

    # Guard: don't emit false recommendations if we can't read the config.
    if ! sudo -u postgres psql -tAc "SELECT 1" >/dev/null 2>&1; then
        warn "PostgreSQL active but querying impossible (permissions/socket) — recommendations skipped"
        return
    fi

    local pg_version
    pg_version=$(sudo -u postgres psql -t -c "SELECT version();" 2>/dev/null | head -1 | xargs)
    info "Version: ${pg_version}"
    if [ -n "${NC_DBTYPE:-}" ] && [ "$NC_DBTYPE" != "pgsql" ]; then
        info "PostgreSQL is running but this Nextcloud instance uses \"${NC_DBTYPE}\" — recos below for reference only (another host usage?)"
    fi

    subheader "Memory settings"
    local shared_buffers effective_cache work_mem maint_work_mem max_conn
    shared_buffers=$(get_pg_setting "shared_buffers" "128MB")
    effective_cache=$(get_pg_setting "effective_cache_size" "4GB")
    work_mem=$(get_pg_setting "work_mem" "4MB")
    maint_work_mem=$(get_pg_setting "maintenance_work_mem" "64MB")
    max_conn=$(get_pg_setting "max_connections" "100")

    local shared_mb effective_mb work_mb maint_mb
    shared_mb=$(pg_to_mb "$shared_buffers")
    effective_mb=$(pg_to_mb "$effective_cache")
    work_mb=$(pg_to_mb "$work_mem")
    maint_mb=$(pg_to_mb "$maint_work_mem")
    export DB_PG_MB="$shared_mb"   # shared_buffers (real alloc) for the budget reconciliation

    info "shared_buffers = ${shared_buffers}"
    info "effective_cache_size = ${effective_cache}"
    info "work_mem = ${work_mem}"
    info "maintenance_work_mem = ${maint_work_mem}"
    info "max_connections = ${max_conn}"

    # RAM-based recommendations
    local recommended_shared recommended_effective recommended_work recommended_maint
    recommended_shared=$(( ${SERVER_RAM_BUDGET_MB:-2048} / 4 ))
    recommended_effective=$(( ${SERVER_RAM_BUDGET_MB:-2048} * 3 / 4 ))
    recommended_work=$(( ${SERVER_RAM_BUDGET_MB:-2048} / ${max_conn:-100} ))
    recommended_maint=$(( ${SERVER_RAM_BUDGET_MB:-2048} / 16 ))

    # Reasonable limits
    [ "$recommended_shared" -gt 8192 ] && recommended_shared=8192
    [ "$recommended_work" -gt 256 ] && recommended_work=256
    [ "$recommended_work" -lt 4 ] && recommended_work=4
    [ "$recommended_maint" -gt 2048 ] && recommended_maint=2048
    [ "$recommended_maint" -lt 64 ] && recommended_maint=64

    # shared_buffers: ~25% of RAM
    if [ "$shared_mb" -lt "$((recommended_shared / 2))" ]; then
        warn "shared_buffers too low (${shared_buffers})"
        recommend "Set to ~${recommended_shared}MB (~25% of RAM)"
    elif [ "$shared_mb" -gt "$((recommended_shared * 2))" ]; then
        warn "shared_buffers very high (${shared_buffers})"
        recommend "Reduce to ~${recommended_shared}MB (~25% of RAM)"
    else
        ok "shared_buffers = ${shared_buffers} (recommended ~${recommended_shared}MB)"
    fi

    # effective_cache_size: ~75% of RAM
    if [ "$effective_mb" -lt "$((recommended_effective / 2))" ]; then
        warn "effective_cache_size low (${effective_cache})"
        recommend "Set to ~${recommended_effective}MB (~75% of RAM)"
    else
        ok "effective_cache_size = ${effective_cache}"
    fi

    # work_mem
    if [ "$work_mb" -lt 4 ]; then
        warn "work_mem very low (${work_mem})"
        recommend "Set to ${recommended_work}MB"
    else
        ok "work_mem = ${work_mem} (recommended ~${recommended_work}MB)"
    fi

    # maintenance_work_mem
    if [ "$maint_mb" -lt 64 ]; then
        warn "maintenance_work_mem low (${maint_work_mem})"
        recommend "Set to ${recommended_maint}MB"
    else
        ok "maintenance_work_mem = ${maint_work_mem}"
    fi

    # WAL
    subheader "WAL and checkpoints"
    local wal_buffers checkpoint_completion checkpoint_timeout
    wal_buffers=$(get_pg_setting "wal_buffers" "-1")
    checkpoint_completion=$(get_pg_setting "checkpoint_completion_target" "0.9")
    checkpoint_timeout=$(get_pg_setting "checkpoint_timeout" "5min")

    info "wal_buffers = ${wal_buffers}"
    info "checkpoint_completion_target = ${checkpoint_completion}"
    info "checkpoint_timeout = ${checkpoint_timeout}"

    if [ "$checkpoint_completion" != "0.9" ] 2>/dev/null; then
        warn "checkpoint_completion_target = ${checkpoint_completion}"
        recommend "Set to 0.9 to smooth out writes"
    else
        ok "checkpoint_completion_target = ${checkpoint_completion}"
    fi

    # max_connections
    subheader "Connections"
    if [ "$max_conn" -gt 200 ]; then
        warn "max_connections high (${max_conn})"
        recommend "For Nextcloud, 100-150 is usually enough. Reduce if possible."
    else
        ok "max_connections = ${max_conn}"
    fi

    # I/O and storage: adapt to the detected disk type (SSD vs mechanical).
    subheader "I/O and storage"
    local random_page_cost effective_io
    random_page_cost=$(get_pg_setting "random_page_cost" "4")
    effective_io=$(get_pg_setting "effective_io_concurrency" "1")
    info "random_page_cost = ${random_page_cost}"
    info "effective_io_concurrency = ${effective_io}"
    if [ "${SERVER_DISK_SSD:-unknown}" = "true" ]; then
        if awk "BEGIN{exit !(${random_page_cost:-4} > 2)}" 2>/dev/null; then
            warn "random_page_cost = ${random_page_cost} on SSD"
            recommend "On SSD/NVMe, lower random_page_cost to ~1.1"
        else
            ok "random_page_cost = ${random_page_cost} (SSD-appropriate)"
        fi
        if [ "${effective_io:-1}" -lt 100 ] 2>/dev/null; then
            warn "effective_io_concurrency low (${effective_io}) on SSD"
            recommend "On SSD/NVMe, set effective_io_concurrency to ~200"
        else
            ok "effective_io_concurrency = ${effective_io}"
        fi
    elif [ "${SERVER_DISK_SSD:-unknown}" = "false" ]; then
        ok "I/O settings left at default (mechanical disk)"
    else
        info "Disk type undetermined — I/O not evaluated"
    fi

    # Logging
    subheader "Logging"
    local log_min_duration
    log_min_duration=$(get_pg_setting "log_min_duration_statement" "-1")
    if [ "$log_min_duration" = "-1" ]; then
        info "log_min_duration_statement disabled"
        recommend "Enable at 1000 (ms) to detect slow queries"
    else
        ok "log_min_duration_statement = ${log_min_duration}ms"
    fi
}

# ==============================================================================
# SECTION 7 : MariaDB
# ==============================================================================

audit_mariadb() {
    header "7. MARIADB"

    if ! systemctl is-active --quiet mariadb 2>/dev/null && ! systemctl is-active --quiet mysql 2>/dev/null; then
        info "MariaDB/MySQL not active — section skipped"
        return
    fi

    # Guard: without access (root/socket auth), no point emitting false recommendations.
    if ! mysql -N -e "SELECT 1" >/dev/null 2>&1; then
        warn "MariaDB/MySQL active but querying impossible (root/socket auth) — recommendations skipped"
        return
    fi

    local db_version
    db_version=$(mysql -N -e "SELECT VERSION()" 2>/dev/null || echo "unknown")
    info "Server version: ${db_version:-unknown}"
    if [ -n "${NC_DBTYPE:-}" ] && [ "$NC_DBTYPE" != "mysql" ]; then
        info "MySQL/MariaDB is running but this Nextcloud instance uses \"${NC_DBTYPE}\" — recos below for reference only (another host usage?)"
    fi

    subheader "InnoDB"
    local innodb_buffer innodb_log_size innodb_flush innodb_io
    innodb_buffer=$(get_mysql_setting "innodb_buffer_pool_size" "0")
    innodb_log_size=$(get_mysql_setting "innodb_log_file_size" "0")
    innodb_flush=$(get_mysql_setting "innodb_flush_log_at_trx_commit" "1")
    innodb_io=$(get_mysql_setting "innodb_io_capacity" "200")

    local innodb_buffer_mb=$(( innodb_buffer / 1024 / 1024 ))
    local innodb_log_mb=$(( innodb_log_size / 1024 / 1024 ))
    export DB_INNODB_MB="$innodb_buffer_mb"   # for the budget reconciliation

    local innodb_flush_method
    innodb_flush_method=$(get_mysql_setting "innodb_flush_method" "")

    info "innodb_buffer_pool_size = ${innodb_buffer_mb} MB"
    info "innodb_log_file_size = ${innodb_log_mb} MB"
    info "innodb_flush_log_at_trx_commit = ${innodb_flush}"
    info "innodb_io_capacity = ${innodb_io}"
    info "innodb_flush_method = ${innodb_flush_method:-default}"

    # innodb_flush_method: O_DIRECT avoids double caching (OS + InnoDB).
    if [ "$innodb_flush_method" = "O_DIRECT" ]; then
        ok "innodb_flush_method = O_DIRECT (recommended)"
    else
        warn "innodb_flush_method = ${innodb_flush_method:-default}"
        recommend "Set innodb_flush_method = O_DIRECT (avoids OS/InnoDB double caching)"
    fi

    # innodb_io_capacity: adapt to the detected disk type.
    if [ "${SERVER_DISK_SSD:-unknown}" = "true" ]; then
        if [ "${innodb_io:-200}" -lt 1000 ] 2>/dev/null; then
            warn "innodb_io_capacity low (${innodb_io}) on SSD"
            recommend "On SSD/NVMe, increase innodb_io_capacity (~2000) and innodb_io_capacity_max"
        else
            ok "innodb_io_capacity = ${innodb_io} (SSD-appropriate)"
        fi
    fi

    # Recommendations
    local recommended_buffer=$(( ${SERVER_RAM_BUDGET_MB:-2048} * 50 / 100 ))
    [ "$recommended_buffer" -gt 32768 ] && recommended_buffer=32768

    if [ "$innodb_buffer_mb" -lt "$((recommended_buffer / 3))" ]; then
        crit "innodb_buffer_pool_size very low (${innodb_buffer_mb} MB)"
        recommend "Set to ~${recommended_buffer} MB (~50-70% of RAM)"
    elif [ "$innodb_buffer_mb" -lt "$((recommended_buffer * 2 / 3))" ]; then
        warn "innodb_buffer_pool_size could be increased (${innodb_buffer_mb} MB)"
        recommend "Set to ~${recommended_buffer} MB (~50-70% of RAM)"
    else
        ok "innodb_buffer_pool_size = ${innodb_buffer_mb} MB (recommended ~${recommended_buffer} MB)"
    fi

    # innodb_log_file_size: classic target ≈ 25% of the buffer pool (enough redo
    # headroom for write bursts). Scale it with the pool, not a flat 64 MB —
    # e.g. 64 MB of redo for an 8 GB pool is far too small. Floor 256 MB; cap
    # 4 GB (a huge redo slows crash recovery and rarely helps a moderate-write
    # Nextcloud DB).
    local recommended_log=$(( innodb_buffer_mb / 4 ))
    [ "$recommended_log" -lt 256 ] && recommended_log=256
    [ "$recommended_log" -gt 4096 ] && recommended_log=4096
    if [ "$innodb_log_mb" -lt "$(( recommended_log / 2 ))" ]; then
        warn "innodb_log_file_size very low (${innodb_log_mb} MB) for a ${innodb_buffer_mb} MB buffer pool"
        recommend "Set to ~${recommended_log} MB (~25% of the buffer pool) for write-burst headroom"
    elif [ "$innodb_log_mb" -lt "$recommended_log" ]; then
        info "innodb_log_file_size = ${innodb_log_mb} MB (could grow toward ~${recommended_log} MB ≈ 25% of the pool)"
    else
        ok "innodb_log_file_size = ${innodb_log_mb} MB (≥ ~25% of the buffer pool)"
    fi

    # flush
    if [ "$innodb_flush" = "1" ]; then
        ok "innodb_flush_log_at_trx_commit = 1 (safe, full ACID)"
        info "Value 2 possible if performance outweighs 100% durability"
    elif [ "$innodb_flush" = "2" ]; then
        warn "innodb_flush_log_at_trx_commit = 2 (loss risk on OS crash)"
    elif [ "$innodb_flush" = "0" ]; then
        crit "innodb_flush_log_at_trx_commit = 0 (high data-loss risk)"
        recommend "Set to 1 for data safety"
    fi

    # Connections
    subheader "Connections"
    local max_conn
    max_conn=$(get_mysql_setting "max_connections" "151")
    info "max_connections = ${max_conn}"
    if [ "$max_conn" -gt 300 ]; then
        warn "max_connections high (${max_conn})"
        recommend "For Nextcloud, 150-200 is usually enough"
    else
        ok "max_connections = ${max_conn}"
    fi

    # Character set
    subheader "Encoding"
    local charset
    charset=$(get_mysql_setting "character_set_server" "")
    local collation
    collation=$(get_mysql_setting "collation_server" "")
    info "character_set_server = ${charset}"
    info "collation_server = ${collation}"

    if [ "$charset" = "utf8mb4" ]; then
        ok "Encoding utf8mb4 (recommended for Nextcloud)"
    elif [ "$charset" = "utf8mb3" ] || [ "$charset" = "utf8" ]; then
        warn "Encoding utf8/utf8mb3 (old) — utf8mb4 recommended"
        recommend "Switch to utf8mb4 for full Unicode support (emojis…)"
    else
        warn "character_set_server = ${charset:-?} — Nextcloud requires utf8mb4"
        recommend "Set character_set_server=utf8mb4 and collation_server=utf8mb4_general_ci (NC handles its tables in utf8mb4 if mysql.utf8mb4=true)"
    fi

    # Slow query log
    subheader "Logging"
    local slow_query
    slow_query=$(get_mysql_setting "slow_query_log" "OFF")
    local slow_time
    slow_time=$(get_mysql_setting "long_query_time" "10")
    if [ "$slow_query" = "ON" ]; then
        ok "Slow query log enabled (threshold: ${slow_time}s)"
    else
        info "Slow query log disabled"
        recommend "Enable slow_query_log with long_query_time = 1 to detect slow queries"
    fi
}

# ==============================================================================
# SECTION 8 : System security
# ==============================================================================

audit_security() {
    header "8. SYSTEM SECURITY"

    # Brute-force protection: fail2ban OR CrowdSec (a modern alternative).
    subheader "Brute-force protection"
    local f2b_active="no" cs_active="no"
    systemctl is-active --quiet fail2ban 2>/dev/null && f2b_active="yes"
    if command -v cscli >/dev/null 2>&1 && systemctl is-active --quiet crowdsec 2>/dev/null; then
        cs_active="yes"
    fi

    if [ "$f2b_active" = "yes" ]; then
        ok "Fail2ban active"
        if fail2ban-client status 2>/dev/null | grep -qi "nextcloud"; then
            ok "Nextcloud jail configured in fail2ban"
        else
            warn "No fail2ban jail for Nextcloud"
            recommend "Add a fail2ban jail for Nextcloud"
        fi
    elif [ "$cs_active" = "yes" ]; then
        ok "CrowdSec active (brute-force protection — a fail2ban alternative)"
        # A bouncer is what actually BLOCKS; without one CrowdSec only detects.
        local nb_bouncers
        nb_bouncers=$(cscli bouncers list -o raw 2>/dev/null | tail -n +2 | grep -c .)
        if [ "${nb_bouncers:-0}" -ge 1 ] 2>/dev/null; then
            ok "CrowdSec bouncer(s) present: ${nb_bouncers} (decisions are enforced)"
        else
            warn "CrowdSec has no bouncer — it detects but does NOT block"
            recommend "Install a bouncer, e.g. apt install crowdsec-firewall-bouncer-nftables"
        fi
        # CAPI = community blocklist of malicious IPs (the main value of CrowdSec).
        if cscli capi status >/dev/null 2>&1; then
            ok "CrowdSec registered with the Central API (community blocklist active)"
        else
            warn "CrowdSec not connected to the Central API — no community blocklist"
            recommend "Register with the CAPI: cscli capi register, then restart crowdsec"
        fi
        # Web console enrollment (optional — dashboard/GUI only, distinct from CAPI).
        if cscli console status 2>/dev/null | grep -qiE "enrolled|true"; then
            info "Enrolled in the CrowdSec web console"
        fi
    else
        warn "No brute-force protection active (neither fail2ban nor CrowdSec)"
        recommend "Install fail2ban (+ a Nextcloud jail) or CrowdSec (+ a bouncer)"
    fi

    # Firewall
    subheader "Firewall"
    if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "active"; then
        ok "UFW active"
    elif command -v nft &>/dev/null && nft list ruleset 2>/dev/null | grep -q "table"; then
        ok "nftables configured"
    elif command -v iptables &>/dev/null && iptables-save 2>/dev/null | grep -qE "DROP|REJECT"; then
        ok "iptables: filtering rules detected"
    else
        warn "No firewall detected or configured"
        recommend "Configure UFW or nftables"
    fi

    # Unattended upgrades
    subheader "Automatic updates"
    if dpkg -l | grep -q "unattended-upgrades" 2>/dev/null; then
        ok "unattended-upgrades installed"
        if systemctl is-active --quiet unattended-upgrades 2>/dev/null; then
            ok "unattended-upgrades service active"
        fi
    else
        warn "unattended-upgrades not installed"
        recommend "Install unattended-upgrades for automatic security updates"
    fi

    # Certbot / Let's Encrypt
    subheader "SSL certificates"
    if command -v certbot &>/dev/null; then
        ok "Certbot installed"
        local certs
        certs=$(certbot certificates 2>/dev/null | grep "Domains:" | wc -l)
        info "${certs} Let's Encrypt certificate(s) managed"

        # Check automatic renewal
        if systemctl is-active --quiet certbot.timer 2>/dev/null; then
            ok "Automatic renewal timer active"
        elif crontab -l 2>/dev/null | grep -q "certbot"; then
            ok "Certbot renewal cron configured"
        else
            warn "No automatic renewal detected"
            recommend "Check that certbot.timer is enabled"
        fi
    fi

    # Pending updates / reboot / service restarts (maintenance hygiene).
    # Complements unattended-upgrades (preventive) with the detective view: is
    # there a real backlog right now? Point-in-time signals (change daily).
    subheader "Pending updates & restarts"
    if command -v apt-get >/dev/null 2>&1; then
        local sec_pending all_pending
        sec_pending=$(apt-get -s upgrade 2>/dev/null | grep '^Inst' | grep -ci security)
        all_pending=$(apt-get -s upgrade 2>/dev/null | grep -c '^Inst')
        if [ "${sec_pending:-0}" -gt 0 ] 2>/dev/null; then
            warn "${sec_pending} security update(s) pending"
            recommend "Apply soon: apt update && apt upgrade (prioritise security)"
        elif [ "${all_pending:-0}" -gt 0 ] 2>/dev/null; then
            info "${all_pending} package update(s) available (none flagged security)"
        else
            ok "System packages up to date"
        fi
    fi

    # Reboot required (a kernel/library upgrade is applied but not yet running).
    if [ -f /var/run/reboot-required ]; then
        warn "Reboot required (a kernel/library upgrade is pending a reboot)"
        recommend "Schedule a reboot during the maintenance window"
    fi

    # Services on stale libraries + kernel status (needrestart, read-only -b mode).
    if command -v needrestart >/dev/null 2>&1; then
        local nr ksta nsvc
        nr=$(needrestart -b 2>/dev/null)
        ksta=$(echo "$nr" | awk -F'[: ]+' '/NEEDRESTART-KSTA/{print $2; exit}')
        nsvc=$(echo "$nr" | grep -c '^NEEDRESTART-SVC')
        if [ "${ksta:-0}" -ge 2 ] 2>/dev/null; then
            warn "Kernel: a newer version is installed but not running (reboot needed)"
        fi
        if [ "${nsvc:-0}" -gt 0 ] 2>/dev/null; then
            warn "${nsvc} service(s) still running on outdated libraries"
            recommend "Restart them (e.g. needrestart -r a, or restart the listed services)"
        fi
        if [ "${ksta:-0}" -lt 2 ] 2>/dev/null && [ "${nsvc:-0}" -eq 0 ] 2>/dev/null; then
            ok "No service or kernel restart pending (needrestart)"
        fi
    else
        info "needrestart absent — can't detect services running on stale libraries"
        recommend "Install for deeper checks: apt install needrestart"
    fi
}

# ==============================================================================
# SECTION 8b : RAM budget reconciliation (no per-layer over-subscription)
# ==============================================================================

# Each section sizes its own layer independently against SERVER_RAM_BUDGET_MB,
# which can over-subscribe (PHP + DB + Apache > 100%). Here we sum the ACTUAL
# configured big consumers and check the total against real RAM. The values are
# exported by the sections that ran before: DB_INNODB_MB / DB_PG_MB (databases),
# FPM_TOTAL_MB (sum over all FPM pools), APACHE_MEM_MB (workers, model-aware).
audit_budget() {
    header "8b. RAM BUDGET RECONCILIATION"

    local ram_mb="${SERVER_RAM_MB:-0}"
    local db_mb=$(( ${DB_INNODB_MB:-0} + ${DB_PG_MB:-0} ))
    local php_mb="${FPM_TOTAL_MB:-0}"
    local apache_mb="${APACHE_MEM_MB:-0}"
    local total=$(( db_mb + php_mb + apache_mb ))

    if [ "$total" -eq 0 ] || [ "$ram_mb" -eq 0 ]; then
        info "Not enough data to reconcile the budget (DB/PHP/Apache memory not all readable)"
        return
    fi

    info "Databases (InnoDB + PG shared_buffers) : ${db_mb} MB"
    info "PHP-FPM pools (Σ children × ~50 MB)    : ${php_mb} MB"
    info "Apache workers (model-aware)           : ${apache_mb} MB"
    info "Sum of the main consumers              : ${total} MB"
    info "Physical RAM                           : ${ram_mb} MB"

    # Keep ~15% for the OS, the page cache and per-connection DB buffers.
    local safe=$(( ram_mb * 85 / 100 ))
    if [ "$total" -gt "$ram_mb" ]; then
        crit "Main consumers (${total} MB) exceed physical RAM (${ram_mb} MB) — OOM risk under load"
        recommend "Reduce the largest layer (usually PHP pm.max_children, then InnoDB) so the sum fits"
    elif [ "$total" -gt "$safe" ]; then
        warn "Main consumers (${total} MB) exceed ~85% of RAM (${safe} MB) — little headroom"
        recommend "Trim a layer (PHP first) to keep ~15% for OS/cache/per-connection buffers"
    else
        ok "Main consumers (${total} MB) fit within RAM with headroom (≤ ${safe} MB)"
    fi
}

# ==============================================================================
# SECTION 9 : Deep analysis (optional tools, read-only)
# ==============================================================================

audit_deep() {
    header "9. DEEP ANALYSIS (optional tools)"
    info "These analyses only run if the tool is ALREADY present (nothing is installed)."

    local suggestions=()

    # --- MySQL/MariaDB: mysqltuner / pt-variable-advisor (read-only) ---
    if systemctl is-active --quiet mariadb 2>/dev/null || systemctl is-active --quiet mysql 2>/dev/null; then
        if mysql -N -e "SELECT 1" >/dev/null 2>&1; then
            if command -v mysqltuner >/dev/null 2>&1; then
                subheader "mysqltuner (runtime recommendations)"
                timeout 90 mysqltuner --nocolor --nogood --noinfo 2>&1 </dev/null | tail -n 60 | emit
            else
                suggestions+=("mysqltuner")
            fi
            if command -v pt-variable-advisor >/dev/null 2>&1; then
                subheader "pt-variable-advisor (Percona Toolkit)"
                local sock
                sock=$(mysql -N -e "SELECT @@socket" 2>/dev/null)
                { timeout 60 pt-variable-advisor "S=${sock}" 2>/dev/null \
                  || timeout 60 pt-variable-advisor h=localhost 2>/dev/null; } | emit
            else
                suggestions+=("percona-toolkit")
            fi
        else
            info "Database active but not queryable (auth/socket) — deep DB analyses skipped"
        fi
    fi

    # --- Apache: apache2buddy.pl (sizing from the processes' real RAM) ---
    # NB: apache2buddy.pl (Perl, maintained fork). The old apachebuddy.pl is abandoned.
    if systemctl is-active --quiet apache2 2>/dev/null; then
        local ab=""
        command -v apache2buddy >/dev/null 2>&1 && ab="apache2buddy"
        [ -z "$ab" ] && [ -f /usr/local/bin/apache2buddy.pl ] && ab="perl /usr/local/bin/apache2buddy.pl"
        if [ -n "$ab" ]; then
            subheader "apache2buddy"
            # We cut apache2buddy's footer ("IMPORTANT MESSAGE" / "DOMAIN EXPIRY
            # NOTICE"): off-topic and anxiety-inducing in this report.
            # shellcheck disable=SC2086
            timeout 60 $ab 2>&1 </dev/null | sed '/IMPORTANT MESSAGE/,$d' | head -n 50 | emit
        else
            suggestions+=("apache2buddy (Perl — richardforth/apache2buddy)")
        fi
    fi

    # --- Disk: iostat (await / %util) ---
    if command -v iostat >/dev/null 2>&1; then
        subheader "iostat (disk I/O, 2 samples)"
        timeout 15 iostat -dx 1 2 2>&1 | sed -n '/Device/,$p' | tail -n 20 | emit
    else
        suggestions+=("sysstat")
    fi

    # --- History: RAM/CPU peaks via sar (sysstat data already collected) ---
    if command -v sar >/dev/null 2>&1; then
        subheader "sar history (sysstat)"
        local cpu_peak mem_peak
        cpu_peak=$(sar -u 2>/dev/null | awk '/^[0-9]/ && NF>=8 {u=100-$NF; if(u>m)m=u} END{if(m)printf "%.0f",m}')
        mem_peak=$(sar -r 2>/dev/null | awk 'NR<=3{for(i=1;i<=NF;i++) if($i=="%memused") c=i} c && /^[0-9]/ {if($c>m)m=$c} END{if(m)printf "%.0f",m}')
        if [ -n "$cpu_peak" ] || [ -n "$mem_peak" ]; then
            [ -n "$cpu_peak" ] && info "CPU peak (today's data): ${cpu_peak}%"
            [ -n "$mem_peak" ] && info "Peak memory used (today's data): ${mem_peak}%"
            if [ -n "$cpu_peak" ] && [ "$cpu_peak" -gt 85 ] 2>/dev/null; then
                warn "High CPU peaks (${cpu_peak}%)"
                recommend "Check the load / consider more cores"
            fi
            if [ -n "$mem_peak" ] && [ "$mem_peak" -gt 90 ] 2>/dev/null; then
                warn "High memory peaks (${mem_peak}%)"
                recommend "Check the RAM / cache sizing"
            fi
        else
            info "sar present but no usable data (is sysstat active and collection recent?)"
        fi
    else
        suggestions+=("sysstat")
    fi

    # --- Suggestions (without installing anything) ---
    if [ "${#suggestions[@]}" -gt 0 ]; then
        local list
        list=$(printf '%s\n' "${suggestions[@]}" | sort -u | paste -sd, - | sed 's/,/, /g')
        info "Deep-analysis tools absent on this server: ${list} — installation in the \"Dependencies\" section above."
    fi
}

# ==============================================================================
# SECTION 10 : Summary and priority recommendations
# ==============================================================================

print_summary() {
    header "AUDIT SUMMARY"

    log_raw ""
    log_raw "  Server: $(hostname) — $(date)"
    log_raw "  Script: nc-audit.sh v${AUDIT_VERSION}"
    log_raw ""
    log_raw "  ${GREEN}✔ OK${NC}    : ${OK_COUNT}"
    log_raw "  ${YELLOW}⚠ WARN${NC}  : ${WARN_COUNT}"
    log_raw "  ${RED}✘ CRIT${NC}  : ${CRIT_COUNT}"
    log_raw "  ${CYAN}ℹ INFO${NC}  : ${INFO_COUNT}"
    log_raw ""

    if [ "$CRIT_COUNT" -gt 0 ]; then
        log_raw "  ${RED}${BOLD}⚠ ${CRIT_COUNT} critical issue(s) to fix as a priority${NC}"
    fi
    if [ "$WARN_COUNT" -gt 0 ]; then
        log_raw "  ${YELLOW}${BOLD}→ ${WARN_COUNT} warning(s) to review${NC}"
    fi
    if [ "$CRIT_COUNT" -eq 0 ] && [ "$WARN_COUNT" -eq 0 ]; then
        log_raw "  ${GREEN}${BOLD}✔ Everything looks well configured!${NC}"
    fi

    log_raw ""
    log_raw "  Full report saved to: ${REPORT_FILE}"
    log_raw ""
}

# ==============================================================================
# INTERACTIVE: --tune-fpm (detect pools, ask the admin a weight for each)
# ==============================================================================

# Opt-in, terminal-only helper: lists the FPM pools and prompts the operator for
# a relative weight per instance (the weight is a HUMAN judgment — the script
# never guesses it), then splits the PHP share of the budget into a target
# pm.max_children per pool. Prints the equivalent NC_INSTANCES string for reuse.
# Strictly interactive: reads from /dev/tty, so it never runs in cron/--json/pipe.
audit_tune_fpm() {
    # Needs a controlling terminal (this mode is interactive by design).
    if ! { exec 3<>/dev/tty; } 2>/dev/null; then
        echo "ERROR: --tune-fpm needs an interactive terminal (not usable in cron/pipe/--json)." >&2
        exit 1
    fi

    local total_ram_mb budget_mb php_share
    total_ram_mb=$(( $(grep MemTotal /proc/meminfo | awk '{print $2}') / 1024 ))
    budget_mb=$(( total_ram_mb * NC_RAM_BUDGET_PCT / 100 ))
    php_share=$(( budget_mb * ${PHP_BUDGET_SHARE_PCT:-60} / 100 ))

    # Distinct pools (dedup by name, like the audit), keeping the max children.
    local pool_f pc pool_name
    local -A pool_children=()
    while IFS= read -r pool_f; do
        [ -f "$pool_f" ] || continue
        pc=$(get_ini_value "$pool_f" "pm.max_children" "0")
        [[ "$pc" =~ ^[0-9]+$ ]] || pc=0
        pool_name=$(basename "$pool_f" .conf)
        [ "${pool_children[$pool_name]:-0}" -lt "$pc" ] && pool_children[$pool_name]="$pc"
    done < <(find /etc/php/ -path "*/pool.d/*.conf" 2>/dev/null | sort)

    if [ "${#pool_children[@]}" -lt 2 ]; then
        echo "Found ${#pool_children[@]} FPM pool — --tune-fpm is for multi-instance hosts (2+ pools)." >&2
        exec 3>&- ; exit 0
    fi

    local rest_mb=$(( budget_mb - php_share ))
    local php_pct="${PHP_BUDGET_SHARE_PCT:-60}" rest_pct=$(( 100 - ${PHP_BUDGET_SHARE_PCT:-60} ))
    printf '%b\n' "${BOLD}${BLUE}=== Interactive FPM pool tuning ===${NC}" >&3
    printf 'RAM %s MB · stack budget %s%% = %s MB\n' \
        "$total_ram_mb" "$NC_RAM_BUDGET_PCT" "$budget_mb" >&3
    printf 'PHP share (%s%% of budget) = %s MB · the other %s%% (~%s MB) covers DB + web + OS\n\n' \
        "$php_pct" "$php_share" "$rest_pct" "$rest_mb" >&3
    printf '%bHow it works%b\n' "${BOLD}" "${NC}" >&3
    printf '  - You give each instance a RELATIVE weight (its importance/load).\n' >&3
    printf '    It is NOT a percentage and NOT megabytes: "2" just means twice the\n' >&3
    printf '    share of "1". Press Enter to keep the default weight of 1.\n' >&3
    printf '  - The %s MB PHP share is split by weight, then / ~50 MB per process:\n' "$php_share" >&3
    printf '        target = PHP_share x (weight / sum_of_weights) / 50\n' >&3
    printf '  - The target is a CEILING the budget allows for pm.max_children, not a\n' >&3
    printf '    value you must set: only raise a pool if it actually saturates.\n\n' >&3

    # Sorted instance names for a stable order.
    local -a names=() weights=()
    local total_w=0 w
    while IFS= read -r pool_name; do
        printf 'Weight for %s (current pm.max_children=%s) [default 1]: ' "$pool_name" "${pool_children[$pool_name]}" >&3
        read -r w <&3
        { [[ "$w" =~ ^[0-9]+$ ]] && [ "$w" -gt 0 ]; } || w=1
        names+=("$pool_name"); weights+=("$w"); total_w=$(( total_w + w ))
    done < <(printf '%s\n' "${!pool_children[@]}" | sort)

    # Compute and print the per-pool targets + the reusable NC_INSTANCES string.
    printf '\n%b\n' "${BOLD}--- Recommended pm.max_children ---${NC}" >&3
    local i target sum_target=0 nc_str=""
    for i in "${!names[@]}"; do
        target=$(( php_share * weights[i] / total_w / 50 ))
        [ "$target" -lt 1 ] && target=1
        sum_target=$(( sum_target + target ))
        printf '  %-22s weight %s/%s → %s  (current %s)\n' \
            "${names[i]}" "${weights[i]}" "$total_w" "$target" "${pool_children[${names[i]}]}" >&3
        nc_str="${nc_str:+$nc_str,}${names[i]}:${weights[i]}"
    done
    printf '  Σ target = %s children (~%s MB) vs PHP share %s MB\n' \
        "$sum_target" "$(( sum_target * 50 ))" "$php_share" >&3
    printf '\n%bReusable (non-interactive):%b\n' "${BOLD}" "${NC}" >&3
    printf '  NC_RAM_BUDGET_PCT=%s NC_INSTANCES="%s"\n' "$NC_RAM_BUDGET_PCT" "$nc_str" >&3
    printf 'Apply by setting each pool to its target pm.max_children, then restart php-fpm.\n' >&3

    exec 3>&-
}

# ==============================================================================
# MAIN
# ==============================================================================

# Runs the full audit (banner + all sections + summary). The output goes to
# stdout AND to $REPORT_FILE; in --json/--push modes we redirect it to
# /dev/null (the counters and $REPORT_FILE stay populated).
_run_audit() {
    log_raw "${BOLD}${BLUE}"
    log_raw "  ███╗   ██╗ ██████╗      █████╗ ██╗   ██╗██████╗ ██╗████████╗"
    log_raw "  ████╗  ██║██╔════╝     ██╔══██╗██║   ██║██╔══██╗██║╚══██╔══╝"
    log_raw "  ██╔██╗ ██║██║          ███████║██║   ██║██║  ██║██║   ██║   "
    log_raw "  ██║╚██╗██║██║          ██╔══██║██║   ██║██║  ██║██║   ██║   "
    log_raw "  ██║ ╚████║╚██████╗     ██║  ██║╚██████╔╝██████╔╝██║   ██║   "
    log_raw "  ╚═╝  ╚═══╝ ╚═════╝     ╚═╝  ╚═╝ ╚═════╝ ╚═════╝ ╚═╝   ╚═╝   "
    log_raw "${NC}"
    log_raw "  ${BOLD}Nextcloud server audit — ézéo${NC}  ${CYAN}v${AUDIT_VERSION}${NC}"
    log_raw "  $(date)"
    log_raw ""

    audit_dependencies
    audit_server
    audit_nextcloud
    audit_php
    audit_apache
    audit_nginx
    audit_postgresql
    audit_mariadb
    audit_security
    audit_deep
    audit_budget
    print_summary
}

# Builds the JSON envelope (counters + text report) to push to the monitor.
# Requires jq. SERVER_URL may be empty in local --json.
audit_emit_json() {
    local report
    report=$(cat "$REPORT_FILE" 2>/dev/null)
    jq -n \
        --arg server_url "${SERVER_URL:-}" \
        --arg audit_version "$AUDIT_VERSION" \
        --argjson generated_at "$(date +%s)" \
        --arg hostname "$(hostname)" \
        --argjson ok "${OK_COUNT:-0}" \
        --argjson warn "${WARN_COUNT:-0}" \
        --argjson crit "${CRIT_COUNT:-0}" \
        --argjson info "${INFO_COUNT:-0}" \
        --arg report "$report" \
        '{type:"audit", server_url:$server_url, audit_version:$audit_version,
          generated_at:$generated_at, hostname:$hostname,
          counters:{ok:$ok,warn:$warn,crit:$crit,info:$info}, report:$report}'
}

# Pushes the audit report to the monitors listed in targets-<SLUG>.conf
# (reuses the Push mode config/token). $1 = instance config file.
audit_push() {
    local conf="$1"
    command -v jq >/dev/null 2>&1 || { echo "ERROR: jq required for --push (apt install jq)" >&2; exit 1; }
    [ -n "$conf" ] && [ -f "$conf" ] || { echo "ERROR: --push requires a valid config file (e.g. /etc/ncstatuscheck/<slug>.conf)" >&2; exit 1; }
    if [ -n "$(find "$conf" -perm /022 2>/dev/null)" ]; then
        echo "ERROR: $conf is group/world-writable — refused (chmod 600 $conf)" >&2; exit 1
    fi
    # Safeguard: don't source a file that isn't an instance config
    # (e.g. the targets-*.conf file, a common mistake: url|token lines).
    if ! grep -q '^[[:space:]]*SERVER_URL=' "$conf"; then
        echo "ERROR: $conf doesn't look like an instance config (SERVER_URL missing)." >&2
        echo "       Pass the <slug>.conf file (e.g. /etc/ncstatuscheck/<slug>.conf)," >&2
        echo "       NOT the targets-<slug>.conf file." >&2
        exit 1
    fi
    # shellcheck disable=SC1090
    . "$conf"
    : "${SERVER_URL:?SERVER_URL missing in $conf}"
    : "${SLUG:?SLUG missing in $conf}"
    local targets="/etc/ncstatuscheck/targets-${SLUG}.conf"
    [ -f "$targets" ] || { echo "ERROR: targets file not found: $targets" >&2; exit 1; }

    local json; json=$(audit_emit_json)
    local M_URL M_TOKEN M_USER M_PASS M_AUTH resp rc=0
    while IFS='|' read -r M_URL M_TOKEN M_USER M_PASS; do
        [ -z "$M_URL" ] && continue
        case "$M_URL" in '#'*) continue ;; esac
        [ -z "$M_TOKEN" ] && continue
        M_AUTH=""; [ -n "$M_USER" ] && M_AUTH="--user $M_USER:$M_PASS"
        # shellcheck disable=SC2086
        resp=$(curl -s -X POST "$M_URL/push-api.php?action=push_audit" \
            -H "Content-Type: application/json" \
            -H "X-Push-Token: $M_TOKEN" \
            --max-time 30 $M_AUTH \
            -d "$json" 2>&1)
        if echo "$resp" | grep -q '"status":"ok"'; then
            echo "OK: audit sent to $M_URL"
        else
            echo "ERROR: audit send failed to $M_URL — $resp" >&2; rc=1
        fi
    done < "$targets"
    exit "$rc"
}

main() {
    # Check root permissions
    if [ "$EUID" -ne 0 ]; then
        echo "This script must be run as root (or with sudo)."
        exit 1
    fi

    # Mode: --json (prints the JSON), --push <conf> (sends to the monitor), or
    # default (terminal report). We strip the flag from the positionals so that
    # $1 becomes the possible Nextcloud path again.
    local push_conf=""
    AUDIT_MODE="report"
    case "${1:-}" in
        --json) AUDIT_MODE="json"; set -- "${@:2}" ;;
        --push) AUDIT_MODE="push"; push_conf="${2:-}"; set -- "${@:3}" ;;
        --tune-fpm) AUDIT_MODE="tune"; set -- "${@:2}" ;;
    esac

    # Nextcloud path: 1st positional argument (takes precedence) or NC_PATH variable.
    if [ -n "${1:-}" ]; then
        export NC_CUSTOM_PATH="$1"
    elif [ -n "${NC_PATH:-}" ]; then
        export NC_CUSTOM_PATH="$NC_PATH"
    fi

    # RAM budget allocated to the Nextcloud stack (web+PHP+DB), as a % of total RAM.
    NC_RAM_BUDGET_PCT="${NC_RAM_BUDGET_PCT:-100}"
    if ! [[ "$NC_RAM_BUDGET_PCT" =~ ^[0-9]+$ ]] || [ "$NC_RAM_BUDGET_PCT" -lt 10 ] || [ "$NC_RAM_BUDGET_PCT" -gt 100 ]; then
        echo "Invalid NC_RAM_BUDGET_PCT (${NC_RAM_BUDGET_PCT}) — expected a value between 10 and 100. Using 100." >&2
        NC_RAM_BUDGET_PCT=100
    fi
    export NC_RAM_BUDGET_PCT

    # PHP's share of the stack budget (the rest covers DB + web + OS). Default
    # 60%. Power-user override for atypical servers (DB-heavy → lower it).
    PHP_BUDGET_SHARE_PCT="${NC_PHP_SHARE_PCT:-60}"
    if ! [[ "$PHP_BUDGET_SHARE_PCT" =~ ^[0-9]+$ ]] || [ "$PHP_BUDGET_SHARE_PCT" -lt 20 ] || [ "$PHP_BUDGET_SHARE_PCT" -gt 90 ]; then
        echo "Invalid NC_PHP_SHARE_PCT (${PHP_BUDGET_SHARE_PCT}) — expected 20..90. Using 60." >&2
        PHP_BUDGET_SHARE_PCT=60
    fi
    export PHP_BUDGET_SHARE_PCT

    # Interactive FPM tuning: standalone, no full audit, no report file.
    if [ "$AUDIT_MODE" = "tune" ]; then
        audit_tune_fpm
        exit 0
    fi

    # Initialize the report
    echo "# Nextcloud audit (nc-audit.sh v${AUDIT_VERSION}) - $(hostname) - $(date)" > "$REPORT_FILE"
    echo "" >> "$REPORT_FILE"

    if [ "$AUDIT_MODE" = "report" ]; then
        _run_audit
    else
        # Silent: stdout reserved for JSON (json) or unused (push).
        _run_audit >/dev/null
    fi

    case "$AUDIT_MODE" in
        json) audit_emit_json ;;
        push) audit_push "$push_conf" ;;
    esac
}

main "$@"