#!/usr/bin/env bash
# /usr/sbin/secubox-tuning-apply — apply swap + cgroup memory/CPU tuning.
#
# Idempotent. Reads config from /etc/secubox/tuning/.
#
# Usage:
#   secubox-tuning-apply               # apply swap + zram + memory caps + CPU caps
#   secubox-tuning-apply swap          # disk swap file only
#   secubox-tuning-apply zram          # compressed RAM swap only
#   secubox-tuning-apply cgroup        # memory caps only
#   secubox-tuning-apply cpu           # CPU caps only
#   secubox-tuning-apply --dry-run     # show what would be done

set -euo pipefail

CONFIG_FILE="${SECUBOX_TUNING_CONFIG:-/etc/secubox/tuning/config.toml}"
LXC_CAPS_FILE="/etc/secubox/tuning/lxc-memory-high.toml"
LXC_CPU_FILE="/etc/secubox/tuning/lxc-cpu.toml"

CYAN='\033[0;36m'
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'

log()  { echo -e "${CYAN}[tuning]${NC} $*"; }
ok()   { echo -e "${GREEN}[  ok  ]${NC} $*"; }
warn() { echo -e "${YELLOW}[ warn ]${NC} $*"; }
err()  { echo -e "${RED}[fail  ]${NC} $*" >&2; }

DRY_RUN=0
DO_SWAP=1
DO_ZRAM=1
DO_CGROUP=1
DO_CPU=1

for arg in "$@"; do
  case "$arg" in
    --dry-run) DRY_RUN=1 ;;
    swap)      DO_ZRAM=0; DO_CGROUP=0; DO_CPU=0 ;;
    zram)      DO_SWAP=0; DO_CGROUP=0; DO_CPU=0 ;;
    cgroup)    DO_SWAP=0; DO_ZRAM=0;   DO_CPU=0 ;;
    cpu)       DO_SWAP=0; DO_ZRAM=0;   DO_CGROUP=0 ;;
    -h|--help)
      sed -n '1,/^set -/p' "$0" | grep '^#' | sed 's/^# \?//'
      exit 0
      ;;
    *) err "unknown arg: $arg"; exit 2 ;;
  esac
done

run() {
  if [[ $DRY_RUN -eq 1 ]]; then
    log "DRY-RUN: $*"
  else
    eval "$@"
  fi
}

# Minimal TOML reader using python3 (avoids depending on toml-cli).
toml_get() {
  local file="$1" key="$2" default="${3:-}"
  python3 -c "
import sys
try:
    import tomllib
except ImportError:
    import tomli as tomllib
try:
    d = tomllib.loads(open('$file').read())
    for k in '$key'.split('.'):
        d = d[k]
    print(d)
except (KeyError, FileNotFoundError):
    print('$default')
" 2>/dev/null || echo "$default"
}

toml_table_keys() {
  local file="$1" table="$2"
  python3 -c "
import sys
try:
    import tomllib
except ImportError:
    import tomli as tomllib
d = tomllib.loads(open('$file').read())
for k in d.get('$table', {}):
    print(k)
" 2>/dev/null
}

# conf_lxc <conteneur> — chemin de la config LXC, vide si le conteneur n'existe
# pas. Les deux emplacements sont essayes : /data sur les boards, /var/lib
# ailleurs.
conf_lxc() {
  local ctn="$1" path
  for path in "/data/lxc/${ctn}/config" "/var/lib/lxc/${ctn}/config"; do
    [[ -f "$path" ]] && { echo "$path"; return 0; }
  done
  echo ""
}

# persiste_cle <config> <cle> <valeur> — inscrit `cle = valeur`, EN UNE SEULE
# LIGNE.
#
# LE REPLI DES DOUBLONS N'EST PAS COSMETIQUE. La configuration de `matrix`
# portait deux `lxc.cgroup2.cpu.max` : 200000 puis 60000. LXC retient la
# derniere, donc l'intention de deux coeurs etait morte sans que rien ne le
# signale — on lisait 200000 en haut du fichier et le conteneur tournait a 0,6.
# Un `sed` de remplacement ne suffit pas : il reecrit CHAQUE ligne, et laisse
# donc deux lignes identiques, prete a diverger de nouveau au prochain
# passage a la main.
#
# La cle est un motif litteral (pas une regexp) : les points de
# `lxc.cgroup2.cpu.max` sont echappes ici, pas dans l'appelant.
persiste_cle() {
  local conf="$1" cle="$2" val="$3"
  local motif; motif=$(printf '%s' "$cle" | sed 's/\./\\./g')

  # Ecriture par fichier temporaire puis renommage : une config LXC tronquee
  # par une interruption laisse le conteneur non demarrable.
  local tmp="${conf}.tuning-tmp"
  if grep -qE "^${motif}[[:space:]]*=" "$conf"; then
    # On garde la PREMIERE occurrence a sa place — l'ordre du fichier reste
    # lisible — et on efface les suivantes.
    awk -v motif="^${motif}[[:space:]]*=" -v ligne="${cle} = ${val}" '
      $0 ~ motif { if (!vu++) print ligne; next }
      { print }
    ' "$conf" > "$tmp"
  else
    cp "$conf" "$tmp"
    printf '%s = %s\n' "$cle" "$val" >> "$tmp"
  fi
  mv "$tmp" "$conf"
}

apply_swap() {
  log "swap phase"
  local swap_path size min_free_gb repli
  swap_path=$(toml_get "$CONFIG_FILE" swap.path /data/swapfile)
  size=$(toml_get "$CONFIG_FILE" swap.size 8G)
  min_free_gb=$(toml_get "$CONFIG_FILE" swap.minimum_data_free_gb 20)
  repli=$(toml_get "$CONFIG_FILE" swap.fallback_path /srv/secubox/swapfile)

  # LE ZRAM N'EST PAS UN SWAP DISQUE. Il est actif sur toute box qui a zram-generator ; le tester ici faisait
  # conclure « swap déjà actif » et le fichier disque n'était JAMAIS créé — alors que le zram est de la RAM comprimée
  # et se remplit (gk3 : 3,8 Go de zram pleins, plus rien à évincer, le tueur de mémoire global abat le moteur vocal).
  if swapon --show=NAME --noheadings | grep -vq '^/dev/zram'; then
    ok "disk swap already active: $(swapon --show=NAME --noheadings | grep -v '^/dev/zram' | tr '\n' ' ')"
    return 0
  fi

  # Où placer le fichier : le chemin configuré (/data : le gros disque des boards à eMMC), sinon le repli
  # /srv/secubox (gk3 : le SSD est sous /, /data n'a que 4 Go). Jamais une carte eMMC/SD : l'usure d'un swap la tuerait.
  local candidat dir source free_gb choisi=""
  for candidat in "$swap_path" "$repli"; do
    [[ -n "$candidat" ]] || continue
    dir="$(dirname "$candidat")"
    if [[ ! -d "$dir" ]]; then log "$dir absent, essai suivant"; continue; fi
    source="$(findmnt -no SOURCE -T "$dir" 2>/dev/null || true)"
    if [[ "$source" == /dev/mmcblk* ]]; then log "$dir sur $source (eMMC/SD) : refusé pour un swap"; continue; fi
    free_gb=$(df --output=avail -BG "$dir" | tail -1 | tr -d 'G ')
    if [[ "$free_gb" -lt "$min_free_gb" ]]; then log "$dir : ${free_gb}G libres < ${min_free_gb}G requis, essai suivant"; continue; fi
    choisi="$candidat"
    break
  done
  if [[ -z "$choisi" ]]; then
    warn "aucun emplacement de swap disque convenable ($swap_path, $repli) : swap disque non créé"
    return 0
  fi
  swap_path="$choisi"

  if [[ -f "$swap_path" ]]; then
    log "$swap_path exists, activating"
    run "swapon $swap_path"
  else
    log "creating $swap_path ($size)"
    run "fallocate -l $size $swap_path"
    run "chmod 600 $swap_path"
    run "mkswap $swap_path"
    run "swapon $swap_path"
  fi

  if ! grep -q "^$swap_path " /etc/fstab; then
    log "persisting in /etc/fstab"
    run "echo '$swap_path none swap sw 0 0' >> /etc/fstab"
  fi
  ok "swap: $(swapon --show)"
}

apply_zram() {
  log "zram phase"
  local enabled size algo prio
  enabled=$(toml_get "$CONFIG_FILE" zram.enabled true)
  case "$enabled" in
    True|true|1) : ;;
    *) ok "zram disabled in config, skipping"; return 0 ;;
  esac
  size=$(toml_get "$CONFIG_FILE" zram.size "min(ram / 2, 4096)")
  algo=$(toml_get "$CONFIG_FILE" zram.algorithm lzo-rle)
  prio=$(toml_get "$CONFIG_FILE" zram.priority 100)

  if ! command -v zramctl >/dev/null 2>&1; then
    warn "zramctl absent (util-linux); skipping zram"
    return 0
  fi
  # systemd-zram-generator creates the device and its swap unit at boot;
  # without it, only a live setup would work and nothing would survive a
  # reboot — so require it rather than silently ship a non-persistent swap.
  if [[ ! -e /lib/systemd/system-generators/zram-generator \
     && ! -e /usr/lib/systemd/system-generators/zram-generator ]]; then
    warn "systemd-zram-generator not installed; skipping zram (apt install systemd-zram-generator)"
    return 0
  fi

  # Validate the algorithm against what THIS kernel's zram actually offers;
  # fall back to the universally-supported lzo-rle otherwise. Needs the
  # module loaded so /sys/block/zram0/comp_algorithm exists.
  modprobe zram 2>/dev/null || true
  if [[ -e /sys/block/zram0/comp_algorithm ]] \
     && ! grep -qw "$algo" /sys/block/zram0/comp_algorithm; then
    warn "algo '$algo' unsupported by kernel zram; falling back to lzo-rle"
    algo="lzo-rle"
  fi

  local conf=/etc/systemd/zram-generator.conf tmp
  tmp=$(mktemp)
  cat > "$tmp" <<CONF
# Managed by secubox-tuning-apply — edit /etc/secubox/tuning/config.toml [zram].
# Compressed RAM swap: compresses swapped pages in memory instead of the
# eMMC (faster + spares the flash). Priority is set above the /data swap
# file so the kernel swaps here first.
[zram0]
zram-size = $size
compression-algorithm = $algo
swap-priority = $prio
CONF

  if [[ $DRY_RUN -eq 1 ]]; then
    log "DRY-RUN: would write $conf:"
    sed 's/^/    /' "$tmp"
    rm -f "$tmp"
    return 0
  fi

  if ! cmp -s "$tmp" "$conf" 2>/dev/null; then
    mv "$tmp" "$conf"
    log "wrote $conf (size=$size algo=$algo prio=$prio)"
  else
    rm -f "$tmp"
    log "$conf already current"
  fi

  systemctl daemon-reload
  if ! swapon --show=NAME --noheadings | grep -qx '/dev/zram0'; then
    systemctl restart systemd-zram-setup@zram0.service 2>/dev/null || true
    systemctl start dev-zram0.swap 2>/dev/null || true
  fi

  if swapon --show=NAME --noheadings | grep -qx '/dev/zram0'; then
    ok "zram active: $(zramctl --output NAME,ALGORITHM,DISKSIZE --noheadings /dev/zram0 2>/dev/null) (prio $prio)"
  else
    warn "zram not active after setup — check: systemctl status dev-zram0.swap"
  fi
}

apply_cgroup() {
  log "cgroup MemoryHigh phase"
  if [[ ! -f "$LXC_CAPS_FILE" ]]; then
    warn "$LXC_CAPS_FILE not found, skipping"
    return 0
  fi

  local containers
  containers=$(toml_table_keys "$LXC_CAPS_FILE" caps)
  if [[ -z "$containers" ]]; then
    warn "no caps defined in $LXC_CAPS_FILE"
    return 0
  fi

  # On SecuBox boards LXC containers are started by lxc-autostart at boot,
  # NOT by systemctl start lxc@<name>.service. So drop-ins under
  # /etc/systemd/system/lxc@<name>.service.d/ are ineffective. Apply the
  # cap two ways instead:
  #   1. Direct write to the container's existing cgroup
  #      (/sys/fs/cgroup/lxc.payload.<name>/memory.high) for immediate
  #      effect, no restart.
  #   2. Add lxc.cgroup2.memory.high=<cap> to the container's LXC config
  #      (/var/lib/lxc/<name>/config OR /data/lxc/<name>/config — try
  #      both, take the first that exists) so the cap is reapplied on
  #      every lxc-start.

  local applied=0 persisted=0 missed=0 skipped=0
  for ctn in $containers; do
    local cap cgrp lxc_conf
    cap=$(toml_get "$LXC_CAPS_FILE" "caps.$ctn")

      # RESPECTER LE BUDGET PROPRE DU CONTENEUR (#496) — ne pas le sur-freiner.
      #
      # `memory.high` est le seuil de FREINAGE ; `memory.max` la limite DURE.
      # Certains conteneurs definissent deja leur PROPRE memory.max dans leur
      # config LXC (budget #496 : max + swap.max=0). Si le cap tuning est
      # SUPERIEUR OU EGAL a cette limite dure, il est au mieux redondant, au pire
      # nuisible : l'ancien clamp le ramenait a 85 % de la max, posant un `high`
      # SOUS le working-set du conteneur et l'etranglant en permanence.
      #
      # Constate le 2026-08-19 : nextcloud (max=512M, working-set ~508M, swap=0)
      # portait high=435M et thrashait — timeouts amont nginx / 503 intermittents ;
      # mail, matrix et horde clampes pareil. On laisse desormais le budget #496
      # gouverner SEUL : on saute le conteneur et on retire tout `high` precedemment
      # injecte (runtime -> max, config -> ligne supprimee), sinon un frein nuisible
      # persisterait au prochain demarrage.
      #
      # NB : lecture de la max gardee par conf_lxc (teste l'existence) — un service
      # hote peut laisser un /var/lib/lxc/<nom>/ sans fichier config (ex. mitmproxy) ;
      # awk sur un fichier absent sort en code 2 et, sous `set -euo pipefail`, tuait
      # toute la boucle (seul le 1er conteneur recevait son cap).
      local dur maxconf; maxconf=$(conf_lxc "$ctn")
      dur=""
      [[ -n "$maxconf" ]] && dur=$(awk '/^lxc.cgroup2.memory.max/{print $3}' "$maxconf" 2>/dev/null | head -1)
      if [[ -n "$dur" ]]; then
        local dur_o cap_o
        dur_o=$(numfmt --from=iec "$dur" 2>/dev/null || echo 0)
        cap_o=$(numfmt --from=iec "$cap" 2>/dev/null || echo 0)
        if [[ "$dur_o" -gt 0 && "$cap_o" -ge "$dur_o" ]]; then
          printf "  %-12s budget propre max=%s <= cap=%s — cap tuning ignore (#496 gouverne)\n" \
                 "$ctn" "$dur" "$cap" >&2
          skipped=$((skipped+1))
          if [[ $DRY_RUN -eq 0 ]]; then
            local cgrp_r="/sys/fs/cgroup/lxc.payload.${ctn}"
            [[ -w "$cgrp_r/memory.high" ]] && echo max > "$cgrp_r/memory.high" 2>/dev/null || true
            for cf in "/data/lxc/${ctn}/config" "/var/lib/lxc/${ctn}/config"; do
              [[ -f "$cf" ]] && sed -i '/^lxc\.cgroup2\.memory\.high\s*=/d' "$cf" 2>/dev/null || true
            done
          fi
          continue
        fi
      fi

    # (1) immediate cgroup write
    cgrp="/sys/fs/cgroup/lxc.payload.${ctn}"
    if [[ -d "$cgrp" ]]; then
      run "echo $cap > $cgrp/memory.high"
      applied=$((applied+1))
      printf "  %-12s cgroup memory.high=%s (runtime)" "$ctn" "$cap"
    else
      missed=$((missed+1))
      printf "  %-12s container not running (no cgroup)" "$ctn"
    fi

    # (2) persistent LXC config edit. Look in both standard and /data paths.
    lxc_conf=$(conf_lxc "$ctn")
    if [[ -n "$lxc_conf" ]]; then
      if [[ $DRY_RUN -eq 1 ]]; then
        printf " + DRY-RUN: would set memory.high=%s in %s\n" "$cap" "$lxc_conf"
      else
        # Passe par persiste_cle : l'ancien `sed` reecrivait CHAQUE ligne
        # correspondante et laissait donc les doublons en place — le defaut qui
        # a tue l'intention de deux coeurs sur `matrix` cote CPU.
        persiste_cle "$lxc_conf" "lxc.cgroup2.memory.high" "$cap"
        printf " + persisted in %s\n" "$lxc_conf"
        persisted=$((persisted+1))
      fi
    else
      printf "\n"
    fi
  done

  ok "cgroup caps: $applied runtime + $persisted persisted + $missed missed + $skipped budget-propre"
}

# apply_cpu — plafond CPU dur par conteneur (#1011).
#
# POURQUOI UN PLAFOND EN PLUS DE `cpu.idle` ET `cpu.weight`. Ces deux-la
# repartissent le CPU quand il est DISPUTE. Ils ne bornent rien quand un seul
# conteneur s'emballe : deux ffmpeg de transcodage PeerTube, prioritaires et
# sans plafond, ont porte la charge de gk2 a 113 (#1010). La repartition
# existante n'est pas touchee ici — seul le plafond est ajoute.
apply_cpu() {
  log "cgroup cpu.max phase"
  if [[ ! -f "$LXC_CPU_FILE" ]]; then
    warn "$LXC_CPU_FILE not found, skipping"
    return 0
  fi

  local containers
  containers=$(toml_table_keys "$LXC_CPU_FILE" max)
  if [[ -z "$containers" ]]; then
    warn "no CPU caps defined in $LXC_CPU_FILE"
    return 0
  fi

  local applied=0 persisted=0 absent=0
  for ctn in $containers; do
    local cap cgrp lxc_conf
    cap=$(toml_get "$LXC_CPU_FILE" "max.$ctn")

    # (1) A CHAUD. Le cgroup v2 accepte l'ecriture sur un conteneur en marche :
    # redemarrer pour poser un plafond couperait le service, precisement ce que
    # le plafond protege.
    cgrp="/sys/fs/cgroup/lxc.payload.${ctn}"
    if [[ -d "$cgrp" ]]; then
      run "echo '$cap' > $cgrp/cpu.max"
      applied=$((applied+1))
      printf "  %-20s cpu.max=%-14s (a chaud)" "$ctn" "$cap"
    else
      printf "  %-20s cpu.max=%-14s (a l'arret)" "$ctn" "$cap"
    fi

    # (2) PERSISTANT. Sans cela le plafond disparait au prochain lxc-start.
    lxc_conf=$(conf_lxc "$ctn")
    if [[ -z "$lxc_conf" ]]; then
      # Un conteneur declare mais jamais provisionne n'est pas une erreur : le
      # fichier decrit la flotte cible, toutes les boards n'ont pas tout.
      absent=$((absent+1))
      printf " — non provisionne\n"
    elif [[ $DRY_RUN -eq 1 ]]; then
      printf " + DRY-RUN: cpu.max=%s dans %s\n" "$cap" "$lxc_conf"
    else
      persiste_cle "$lxc_conf" "lxc.cgroup2.cpu.max" "$cap"
      persisted=$((persisted+1))
      printf " + persiste\n"
    fi
  done

  ok "cpu caps: $applied a chaud + $persisted persistes + $absent non provisionnes"
}

log "secubox-tuning-apply (dry_run=$DRY_RUN, swap=$DO_SWAP, zram=$DO_ZRAM, cgroup=$DO_CGROUP, cpu=$DO_CPU)"
[[ $DO_SWAP   -eq 1 ]] && apply_swap
[[ $DO_ZRAM   -eq 1 ]] && apply_zram
[[ $DO_CGROUP -eq 1 ]] && apply_cgroup
[[ $DO_CPU    -eq 1 ]] && apply_cpu

# SORTIE EXPLICITE. Sous `set -e`, un `[[ ... ]] && phase` final rend 1 quand la
# phase est desactivee : `secubox-tuning-apply swap` sortait donc en erreur
# apres avoir parfaitement fonctionne, et un postinst qui l'appelle aurait
# echoue pour cette seule raison.
exit 0
ok "done"
