#!/usr/bin/env bash
# Safe php.ini updater for Debian/Ubuntu PHP layouts.
# Default dry-run. --apply writes with backups and reloads active FPM/mod_php services.
set -uo pipefail
export LC_ALL=C

usage() {
  cat <<'HELP'
Usage: php-set-ini-v3.sh [--dry-run|--apply] [--sapi fpm|cli|apache2|cgi]...
Default is dry-run. Only /etc/php/<version>/<sapi>/php.ini and /etc/php5/<sapi>/php.ini.
On apply: backups, atomic writes, FPM config tests, active-service reloads.
HELP
}
DRYRUN=1
SAPIS=()
while (($#)); do
  case "$1" in
    -a|--apply) DRYRUN=0 ;;
    -n|--dry-run) DRYRUN=1 ;;
    -s|--sapi)
      (($# >= 2)) || { echo 'Missing --sapi value' >&2; exit 2; }
      case "$2" in fpm|cli|apache2|cgi) SAPIS+=("$2");; *) echo "Invalid SAPI: $2" >&2; exit 2;; esac
      shift ;;
    -h|--help) usage; exit 0 ;;
    *) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
  esac
  shift
done

SETTINGS=(
  'session.upload_progress.enabled=Off'
  'post_max_size=512M'
  'upload_max_filesize=512M'
  'display_errors=Off'
  'expose_php=Off'
  'display_startup_errors=Off'
  'log_errors=Off'
  'error_reporting=0'
)

((EUID == 0)) || { echo 'Run as root' >&2; exit 1; }
for cmd in awk cmp cp diff flock mktemp find sort grep stat mv dirname basename; do
  command -v "$cmd" >/dev/null || { echo "Missing command: $cmd" >&2; exit 1; }
done
exec 9>/run/php-set-ini.lock
flock -n 9 || { echo 'Another run is in progress' >&2; exit 1; }
TMPD=$(mktemp -d) || exit 1
trap 'rm -rf -- "$TMPD"' EXIT
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; }
ERRORS=0
WARNINGS=0
CHANGED=0
WRITTEN=0
FAILED=0
err() { log "ERROR: $*" >&2; ERRORS=$((ERRORS+1)); }
warn() { log "WARNING: $*" >&2; WARNINGS=$((WARNINGS+1)); }
selected() {
  ((${#SAPIS[@]} == 0)) && return 0
  local s
  for s in "${SAPIS[@]}"; do [[ $s == "$1" ]] && return 0; done
  return 1
}
active() {
  if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
    systemctl is-active --quiet "$1"
  else
    service "$1" status >/dev/null 2>&1
  fi
}
reload_svc() {
  if command -v systemctl >/dev/null && [[ -d /run/systemd/system ]]; then
    systemctl reload "$1"
  else
    service "$1" reload
  fi
}

# Read and rewrite literal INI keys, ignoring commented keys. Keep one active key.
# PHP INI values use only plain tokens here, not quoted strings.
build_new() {
  local src=$1 dst=$2 s k v stage="$TMPD/stage.$BASHPID"
  cp -- "$src" "$dst" || return 1
  for s in "${SETTINGS[@]}"; do
    k=${s%%=*}; v=${s#*=}
    K="$k" V="$v" awk '
      function trim(x) {sub(/^[ \t]+/,"",x);sub(/[ \t]+$/, "",x);return x}
      function iskey(line,    x,p) {
        x=line; sub(/^[ \t]+/,"",x)
        if (substr(x,1,1)==";" || substr(x,1,1)=="#") return 0
        p=index(x,"="); if (!p) return 0
        return tolower(trim(substr(x,1,p-1)))==tolower(ENVIRON["K"])
      }
      {if (iskey($0)) {
          if (!found++) print ENVIRON["K"] " = " ENVIRON["V"]
          else print "; duplicate disabled by php-set-ini-v3: " $0
        } else print
      }
      END {if (!found) print "\n; managed by php-set-ini-v3\n" ENVIRON["K"] " = " ENVIRON["V"]}
    ' "$dst" > "$stage" || return 1
    mv -f -- "$stage" "$dst" || return 1
  done
}

# Validates all values using the PHP parser with the selected file as php.ini.
# CLI parser is only a syntax/value precheck; FPM validation is separate.
check_php_ini() {
  local ini=$1 ver=$2 cli="php${ver}" dir out
  command -v "$cli" >/dev/null 2>&1 || { warn "$ini: $cli missing; PHP parser check unavailable"; return 0; }
  dir=$(dirname "$ini")
  out=$(PHP_INI_SCAN_DIR= "$cli" -c "$ini" -r 'foreach (["session.upload_progress.enabled","post_max_size","upload_max_filesize","display_errors","display_startup_errors","log_errors","error_reporting"] as $k) {echo $k,"=",ini_get($k),"\n";}' 2>&1) || { err "$ini: PHP parser failed: $out"; return 1; }
  [[ $out == *'upload_max_filesize=512M'* && $out == *'post_max_size=512M'* ]] || { err "$ini: PHP parser did not load expected upload limits: $out"; return 1; }
  return 0
}

# Collect candidates, no symlinks and no unexpected paths.
mapfile -t INIS < <(find /etc/php /etc/php5 -maxdepth 3 -type f -name php.ini 2>/dev/null | sort -u)
((${#INIS[@]})) || { err 'No supported php.ini files found'; exit 1; }
declare -A SERVICE_FILES=() SERVICE_BACKUPS=() SERVICE_ACTIVE=() SERVICE_BIN=()
APACHE_CHANGED=0
APACHE_ACTIVE=0
active apache2 && APACHE_ACTIVE=1
for ini in "${INIS[@]}"; do
  case "$ini" in
    /etc/php/[0-9]*/fpm/php.ini|/etc/php/[0-9]*/cli/php.ini|/etc/php/[0-9]*/apache2/php.ini|/etc/php/[0-9]*/cgi/php.ini)
      ver=${ini#/etc/php/}; ver=${ver%%/*}; sapi=${ini%/php.ini}; sapi=${sapi##*/}; svc="php${ver}-fpm"; bin="php-fpm${ver}" ;;
    /etc/php5/fpm/php.ini|/etc/php5/cli/php.ini|/etc/php5/apache2/php.ini|/etc/php5/cgi/php.ini)
      ver=5; sapi=${ini%/php.ini}; sapi=${sapi##*/}; svc=php5-fpm; bin=php5-fpm ;;
    *) warn "Unexpected path ignored: $ini"; continue ;;
  esac
  selected "$sapi" || continue
  [[ -f $ini && ! -L $ini ]] || { warn "Skipped symlink/non-file: $ini"; continue; }
  new="$TMPD/new.$BASHPID"
  if ! build_new "$ini" "$new"; then err "Unable to build $ini"; FAILED=$((FAILED+1)); continue; fi
  if cmp -s "$ini" "$new"; then log "UNCHANGED $ini"; continue; fi
  CHANGED=$((CHANGED+1))
  log "CHANGE $ini"
  diff -u -- "$ini" "$new" || :
  if ((DRYRUN)); then continue; fi

  # Preserve original metadata; keep a numbered backup alongside the original.
  n=1
  while [[ -e ${ini}-version-${n} ]]; do n=$((n+1)); done
  bak="${ini}-version-${n}"
  if ! cp -a -- "$ini" "$bak"; then err "Backup failed: $ini"; FAILED=$((FAILED+1)); continue; fi
  # Recreate staging in the original filesystem; metadata from original.
  stage=$(mktemp "${ini}.new.XXXXXXXX") || { err "Temp creation failed: $ini"; FAILED=$((FAILED+1)); continue; }
  if ! cp -a -- "$ini" "$stage" || ! cat -- "$new" > "$stage"; then
    err "Staging write failed: $ini"; rm -f -- "$stage"; FAILED=$((FAILED+1)); continue
  fi
  if ! check_php_ini "$stage" "$ver"; then
    rm -f -- "$stage"; FAILED=$((FAILED+1)); continue
  fi
  if ! mv -f -- "$stage" "$ini"; then
    err "Atomic replacement failed: $ini"; rm -f -- "$stage"; FAILED=$((FAILED+1)); continue
  fi
  WRITTEN=$((WRITTEN+1))
  log "WRITTEN $ini (backup $bak)"
  if [[ $sapi == fpm ]]; then
    SERVICE_FILES[$svc]="$ini"; SERVICE_BACKUPS[$svc]="$bak"; SERVICE_BIN[$svc]="$bin"
    if [[ -z ${SERVICE_ACTIVE[$svc]+x} ]]; then
      if active "$svc"; then SERVICE_ACTIVE[$svc]=1; else SERVICE_ACTIVE[$svc]=0; fi
    fi
  elif [[ $sapi == apache2 ]]; then APACHE_CHANGED=1
  fi
  # Warn for known override locations; cannot guarantee effective runtime values.
  dir=$(dirname "$ini")
  if grep -RIE '^[[:space:]]*(php_(admin_)?(value|flag)\[|[[:alnum:]_.]+[[:space:]]*=)' "$dir/pool.d" 2>/dev/null | grep -Eq 'session\.upload_progress\.enabled|post_max_size|upload_max_filesize|display_errors|display_startup_errors|log_errors|error_reporting'; then
    warn "$ini: FPM pool overrides may take precedence"
  fi
done

if ((DRYRUN)); then
  log "DRY-RUN: $CHANGED file(s) would change. Nothing written or reloaded."
else
  for svc in "${!SERVICE_FILES[@]}"; do
    if [[ ${SERVICE_ACTIVE[$svc]} != 1 ]]; then warn "$svc: not running, no reload"; continue; fi
    if ! active "$svc"; then err "$svc stopped before reload"; continue; fi
    bin=${SERVICE_BIN[$svc]}
    if ! command -v "$bin" >/dev/null 2>&1; then
      err "$svc: missing $bin, cannot safely reload"; continue
    fi
    if ! output=$("$bin" -t 2>&1); then
      err "$svc: config test failed: $output"
      if cp -a -- "${SERVICE_BACKUPS[$svc]}" "${SERVICE_FILES[$svc]}"; then log "$svc: php.ini restored"; else err "$svc: ROLLBACK FAILED"; fi
      continue
    fi
    if ! reload_svc "$svc"; then
      err "$svc: reload failed; previous config not automatically restored (manual inspection required)"
      continue
    fi
    if ! active "$svc"; then err "$svc: not active after reload"; continue; fi
    log "RELOADED $svc"
  done
  if ((APACHE_CHANGED)); then
    if ((APACHE_ACTIVE)) && active apache2; then
      if ! command -v apache2ctl >/dev/null; then err 'apache2ctl missing';
      elif apache2ctl -M 2>/dev/null | grep -Eq 'php[0-9_]*_module'; then
        if apache2ctl configtest >/dev/null 2>&1 && reload_svc apache2 && active apache2; then log 'RELOADED apache2'; else err 'apache2 configtest/reload failed'; fi
      else log 'apache2: mod_php not loaded, no reload'; fi
    else warn 'apache2 not running, no reload'; fi
  fi
fi
log "RESULT: candidate_changes=$CHANGED written=$WRITTEN failed=$FAILED errors=$ERRORS warnings=$WARNINGS"
((ERRORS == 0 && FAILED == 0))
