#!/usr/bin/env bash
# ============================================================================
# run_ci.sh · OTO Enterprise OS DTP · miroir LOCAL du gate CI
# ----------------------------------------------------------------------------
# Reproduit en une commande, EN LOCAL, exactement l'ensemble de checks que le
# gate de merge Gitea Actions agrège — sans runner, sans réseau (Sprint 8 ·
# DevOps « monitoring / regression exhaustifs »).
#
# POURQUOI ce script existe : chaque session rejouait à la main une boucle
# ad-hoc « for g in guard_constraints validate_json … ; do bash ci/$g.sh ». Une
# telle liste écrite à la main DÉRIVE en silence de ce que la CI enforce
# réellement (un gate ajouté/retiré dans `.gitea/workflows/ci.yml` n'y était
# jamais répercuté) — la même classe de « vert trompeur » que les gates du
# dépôt traquent. Ce script n'a AUCUNE liste en dur : il DÉRIVE la liste des
# checks depuis `gate.needs` de ci.yml (la source de vérité que
# `ci/check_ci_integrity.sh` verrouille déjà) et exécute, pour chaque job, la
# commande `run:` que ci.yml déclare, dans son `working-directory:`. Il ne peut
# donc ni oublier ni inventer un check : ajouter un gate à la CI le fait
# apparaître ici automatiquement, zéro édition.
#
# PÉRIMÈTRE : uniquement les jobs de `gate.needs` — ce qui exclut, par
# construction et à dessein, les jobs manuels `workflow_dispatch` hors gate
# (ex. `e2e-baseline`/`npm test`, qui exige Node) et le job `gate` lui-même.
# Les jobs restants sont tous soit `bash ci/*.sh` (gates statiques), soit
# `python3 -m unittest` (suites de module) → stdlib pur, aucune install pip,
# aucune commande touchant le VPS. Le décompte exact (jobs / gates / suites)
# est DÉRIVÉ de gate.needs et imprimé à l'exécution — jamais figé dans ce
# commentaire, où il se périmerait en silence (anti-invention CLAUDE.md #6).
#
# CE N'EST PAS un gate (aucun nouveau `ci/*.sh`, aucune nouvelle assertion) :
# c'est un LANCEUR de commodité. Son exactitude est STRUCTURELLE (il exécute ce
# que ci.yml déclare), pas une re-transcription à protéger — inutile donc d'y
# adjoindre un gate N+1 (CLAUDE.md #5).
#
# Usage :
#   bash run_ci.sh            # exécute tout gate.needs (miroir CI complet)
#   bash run_ci.sh --static   # seulement les gates statiques ci/*.sh (rapide)
#   bash run_ci.sh --list     # liste les jobs qui SERAIENT lancés, sans exécuter
#   bash run_ci.sh --help     # affiche cette aide et sort
#
# Sortie : une ligne PASS/FAIL par job + un récapitulatif. Code de sortie 0 ssi
# tous les jobs passent (identique à la sémantique du gate de merge).
# ============================================================================
set -uo pipefail

usage() {
  cat <<'EOF'
run_ci.sh · miroir LOCAL du gate CI (dérivé de gate.needs, sans liste en dur).
Rejoue en une commande, sans runner ni réseau, l'ensemble de checks que le gate
de merge Gitea Actions agrège.

Usage :
  bash run_ci.sh            exécute tout gate.needs (miroir CI complet)
  bash run_ci.sh --static   seulement les gates statiques ci/*.sh (rapide)
  bash run_ci.sh --list     liste les jobs qui SERAIENT lancés, sans exécuter
  bash run_ci.sh --help     affiche cette aide et sort

Sortie : une ligne PASS/FAIL par job + un récapitulatif.
Code de sortie : 0 ssi tous les jobs passent · 1 si un job échoue ·
                 2 option inconnue · 3 hors checkout git / ci.yml illisible.
EOF
}

# --- aide : disponible AVANT toute résolution git (n'exige aucun checkout) ---
case "${1:-}" in -h|--help) usage; exit 0 ;; esac

# --- racine du dépôt (marche depuis n'importe quel sous-dossier) -------------
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
  echo "run_ci.sh : hors d'un checkout git — impossible de localiser ci.yml." >&2
  exit 3
}
cd "$ROOT" || exit 3
CI_YML=".gitea/workflows/ci.yml"
[ -f "$CI_YML" ] || { echo "run_ci.sh : $CI_YML introuvable." >&2; exit 3; }

MODE="all"
case "${1:-}" in
  --static) MODE="static" ;;
  --list)   MODE="list" ;;
  "")       MODE="all" ;;
  *) echo "run_ci.sh : option inconnue « $1 » (attendu : --static | --list | --help)" >&2; exit 2 ;;
esac

# --- parse ci.yml : gate.needs (ordre) + par job {working-directory, run} ----
# Les commentaires de ligne sont retirés d'abord (un « working-directory: » ou
# « bash ci/… » cité en commentaire ne doit JAMAIS être capturé). Émet, sur
# stdout, une ligne par job de gate.needs : "<job>\t<wd|->\t<commande run>".
parse_jobs() {
  awk '
    { line=$0; sub(/^[[:space:]]*#.*$/, "", line) }        # drop full-line comments
    /^jobs:[[:space:]]*$/ { inj=1 }
    # début d’un job : clé à exactement 2 espaces d’indentation
    inj && match(line, /^  ([a-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; next }
    # gate.needs : la liste des jobs bloquants (source de vérité)
    inj && job=="gate" && match(line, /needs:[[:space:]]*\[(.*)\]/, g) {
      n=split(g[1], a, ","); for (i=1;i<=n;i++){ gsub(/[[:space:]]/,"",a[i]); if(a[i]!="") needs[++nn]=a[i] }
    }
    inj && job!="" && match(line, /^[[:space:]]+working-directory:[[:space:]]*(.+)$/, w) { wd[job]=w[1] }
    # commande run: (avec un argument — écarte la clé « run: » nue de defaults)
    inj && job!="" && match(line, /[[:space:]]run:[[:space:]]+(.+)$/, r) { cmd[job]=r[1] }
    END {
      for (i=1;i<=nn;i++){ j=needs[i]; printf "%s\t%s\t%s\n", j, (j in wd ? wd[j] : "-"), (j in cmd ? cmd[j] : "") }
    }
  ' "$CI_YML"
}

mapfile -t ROWS < <(parse_jobs)
[ "${#ROWS[@]}" -gt 0 ] || { echo "run_ci.sh : gate.needs vide ou illisible dans $CI_YML." >&2; exit 3; }

is_static() { case "$1" in bash\ ci/*) return 0 ;; *) return 1 ;; esac; }

# --- décompte DÉRIVÉ (jamais figé) : gates statiques ci/*.sh vs suites module -
# Ventilé depuis les mêmes ROWS que l'exécution, donc toujours exact : ajouter
# un job à gate.needs le reclasse ici automatiquement, zéro édition (#6).
n_gates=0; n_suites=0
for row in "${ROWS[@]}"; do
  IFS=$'\t' read -r _j _w _c <<<"$row"
  [ -n "$_c" ] || continue
  if is_static "$_c"; then n_gates=$((n_gates+1)); else n_suites=$((n_suites+1)); fi
done
n_jobs=$((n_gates + n_suites))

# --- --list : montre le plan, n’exécute rien --------------------------------
if [ "$MODE" = "list" ]; then
  printf '%-28s %-42s %s\n' "JOB" "WORKING-DIR" "RUN"
  for row in "${ROWS[@]}"; do
    IFS=$'\t' read -r job wd cmd <<<"$row"
    printf '%-28s %-42s %s\n' "$job" "$wd" "$cmd"
  done
  printf -- '-- %d jobs dérivés de gate.needs : %d gates statiques ci/*.sh + %d suites de module.\n' \
    "$n_jobs" "$n_gates" "$n_suites"
  exit 0
fi

# --- exécution ---------------------------------------------------------------
LOGDIR="$(mktemp -d)"
trap 'rm -rf "$LOGDIR"' EXIT
pass=0; fail=0; skip=0; failed_jobs=()
echo "run_ci.sh · miroir du gate CI (mode=$MODE) · $(git rev-parse --short HEAD 2>/dev/null || echo '?')"
if [ "$MODE" = "static" ]; then
  echo "gate.needs dérivé : $n_jobs jobs · mode --static → $n_gates gates ci/*.sh seuls ($n_suites suites de module ignorées)"
else
  echo "gate.needs dérivé : $n_jobs jobs = $n_gates gates ci/*.sh + $n_suites suites de module"
fi
echo "------------------------------------------------------------------------"
for row in "${ROWS[@]}"; do
  IFS=$'\t' read -r job wd cmd <<<"$row"
  [ -n "$cmd" ] || { printf '  SKIP  %-28s (aucune commande run: dans ci.yml)\n' "$job"; skip=$((skip+1)); continue; }
  if [ "$MODE" = "static" ] && ! is_static "$cmd"; then continue; fi
  log="$LOGDIR/$job.log"
  if [ "$wd" = "-" ]; then dir="$ROOT"; else dir="$ROOT/$wd"; fi
  if ( cd "$dir" && eval "$cmd" ) >"$log" 2>&1; then
    printf '  PASS  %-28s %s\n' "$job" "$cmd"; pass=$((pass+1))
  else
    printf '  FAIL  %-28s %s\n' "$job" "$cmd"; fail=$((fail+1)); failed_jobs+=("$job")
    sed 's/^/          | /' "$log" | tail -n 12
  fi
done

echo "------------------------------------------------------------------------"
printf 'Récapitulatif : %d PASS · %d FAIL · %d SKIP\n' "$pass" "$fail" "$skip"
if [ "$fail" -ne 0 ]; then
  printf 'Jobs en échec : %s\n' "$(IFS=', '; echo "${failed_jobs[*]}")"
  exit 1
fi
echo "✅ Tous les checks du gate franchis en local."
exit 0
