#!/usr/bin/env bash
# ============================================================================
# check_ci_integrity.sh · OTO Enterprise OS DTP
# ----------------------------------------------------------------------------
# Intégrité du câblage du workflow CI Gitea (gate anti-dérive · Sprint 8 buffer).
#
# Le job d'agrégat `gate` est le SEUL verrou de merge : un check qui n'y figure
# pas dans `needs:` ne bloque RIEN, même rouge. Les couvertures existantes
# (audit_4big/registry, qa/regression/discovery) ne prouvent l'appartenance au
# gate QUE pour les jobs de MODULE (ceux qui portent un `working-directory:` sous
# 05_deliverables_mvp). Les gates STATIQUES (`constraints-guard`, `validate-json`,
# `check-docs`, `check-artifacts`, `check-regression` → scripts `ci/*.sh`) n'ont
# pas de working-directory : rien ne garantissait qu'ils alimentent le gate.
# Retirer l'un d'eux de `needs:` rendait le gate vert en le court-circuitant —
# même classe de dérive silencieuse que INV4 (orphan_tests_dirs), côté câblage CI.
#
# Ce gate prouve, par des FAITS lus dans ci.yml (jamais une liste à la main) :
#   [INV-A] `gate.needs` == { tous les jobs définis } − { gate } − { jobs manuels }
#           (un job manuel = gardé par `if: … workflow_dispatch …`, ex. e2e-baseline,
#            légitimement hors du gate push/PR car il exige un serveur live).
#           → aucun job oublié du gate (MISSING) ni référence fantôme (DANGLING).
#   [INV-B] Chaque script `ci/*.sh` du dépôt est CÂBLÉ : soit lancé par un job
#           (`run: bash ci/<script>`) dont le nom figure dans `gate.needs`, soit
#           SOURCÉ par ≥1 gate script (lib partagée, ex. ci/lib.sh — jamais un
#           job propre). → un nouveau gate statique non câblé/décâblé, ou une lib
#           morte (sourcée par personne), casse le check.
#
# Dépendances : bash, git, awk, grep, sort. Aucun réseau (CLAUDE.md #2).
# ============================================================================
set -uo pipefail
# shellcheck source=ci/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" || exit 3
cd_repo_root  # racine du dépôt, ou ROUGE honnête si hors arbre git (cf. lib.sh)

CI=".gitea/workflows/ci.yml"
FAIL=0
report() { printf '  \033[31m✗\033[0m %s\n' "$1"; FAIL=1; }
ok()     { printf '  \033[32m✓\033[0m %s\n' "$1"; }

if [[ ! -f "$CI" ]]; then
  report "workflow introuvable : $CI"
  echo -e "\n\033[31m❌ CI absent.\033[0m"; exit 1
fi

# --- Extraction (awk · parseur YAML minimal, cohérent avec q4lib/registry) ---
# On ne considère que ce qui suit la ligne `jobs:` (le bloc `on:` a lui aussi des
# clés à 2 espaces : push/pull_request/workflow_dispatch — à ne pas confondre
# avec des jobs). Un job = clé `^  <nom>:` à 2 espaces exactement. Pour chaque
# job on note s'il est MANUEL (son bloc contient `workflow_dispatch` dans un
# garde `if:`), le job du bloc `gate` et sa ligne `needs: [...]`, et les scripts
# `ci/*.sh` lancés par un `run:` (avec le job appelant).

ALL_JOBS="$(awk '
  /^jobs:[[:space:]]*$/ { injobs=1; next }
  injobs && match($0, /^  ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { print m[1] }
' "$CI" | sort)"

MANUAL_JOBS="$(awk '
  /^jobs:[[:space:]]*$/ { injobs=1; next }
  injobs && match($0, /^  ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; next }
  injobs && job != "" && /if:/ && /workflow_dispatch/ { print job }
' "$CI" | sort -u)"

GATE_NEEDS="$(awk '
  /^jobs:[[:space:]]*$/ { injobs=1; next }
  injobs && match($0, /^  ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; next }
  injobs && job=="gate" && match($0, /needs:[[:space:]]*\[(.*)\]/, m) {
    n=split(m[1], a, ",")
    for (i=1;i<=n;i++) { gsub(/[[:space:]]/,"",a[i]); if (a[i]!="") print a[i] }
  }
' "$CI" | sort -u)"

# scripts référencés : "<job> <chemin ci/xxx.sh>"
SCRIPT_REFS="$(awk '
  /^jobs:[[:space:]]*$/ { injobs=1; next }
  injobs && match($0, /^  ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; next }
  injobs && job != "" && match($0, /bash (ci\/[A-Za-z0-9_./-]+\.sh)/, m) { print job, m[1] }
' "$CI" | sort -u)"

# --- INV-A : gate.needs == jobs − gate − manuels -----------------------------
echo "== INV-A · gate.needs couvre tout job non-manuel (aucun oubli / fantôme) =="

if [[ -z "$GATE_NEEDS" ]]; then
  report "job 'gate' sans liste needs: [...] exploitable"
fi

# Ensemble attendu = tous les jobs sauf 'gate' lui-même et les jobs manuels.
EXPECTED="$(comm -23 \
  <(printf '%s\n' "$ALL_JOBS" | grep -vx 'gate') \
  <(printf '%s\n' "$MANUAL_JOBS") )"

# MISSING : attendus mais absents de needs → checks qui ne bloquent pas.
missing="$(comm -23 <(printf '%s\n' "$EXPECTED") <(printf '%s\n' "$GATE_NEEDS"))"
# DANGLING : cités dans needs mais non définis (typo / job renommé/supprimé).
dangling="$(comm -23 <(printf '%s\n' "$GATE_NEEDS") <(printf '%s\n' "$ALL_JOBS"))"
# MANUAL-IN-GATE : un job manuel dans needs bloquerait le gate sur push (jamais
# déclenché) — le gate resterait en attente. Anomalie à signaler.
manual_in_gate="$(comm -12 <(printf '%s\n' "$MANUAL_JOBS") <(printf '%s\n' "$GATE_NEEDS"))"

for j in $missing;        do report "job '$j' défini mais ABSENT de gate.needs → il ne bloque pas le merge"; done
for j in $dangling;       do report "gate.needs référence '$j' qui n'est PAS un job défini (typo / job supprimé)"; done
for j in $manual_in_gate; do report "job manuel '$j' présent dans gate.needs → gate en attente sur push/PR"; done

if [[ -z "$missing$dangling$manual_in_gate" ]]; then
  njobs="$(printf '%s\n' "$ALL_JOBS" | grep -c .)"
  nneeds="$(printf '%s\n' "$GATE_NEEDS" | grep -c .)"
  nman="$(printf '%s\n' "$MANUAL_JOBS" | grep -c . || true)"
  ok "gate.needs = $nneeds jobs = $njobs définis − 1 (gate) − $nman manuel(s) [$(printf '%s' "$MANUAL_JOBS" | paste -sd, -)]"
fi

# --- INV-B : tout ci/*.sh est câblé à un job, lui-même dans le gate ----------
echo "== INV-B · chaque ci/*.sh est câblé (lancé par un job du gate, ou lib sourcée) =="

# Jobs référençant chaque script (via SCRIPT_REFS).
while IFS= read -r script; do
  [[ -z "$script" ]] && continue
  jobs_for="$(printf '%s\n' "$SCRIPT_REFS" | awk -v s="$script" '$2==s {print $1}' | sort -u)"
  if [[ -z "$jobs_for" ]]; then
    # Pas lancé par un job → soit une LIB sourcée (ex. ci/lib.sh), soit un gate
    # mort. Une lib est légitime SSI ≥1 gate script (lui-même lancé par un job)
    # la source. On grepe les gate scripts sur disque pour une ligne
    # `source …/<base>` ou `. …/<base>` (jamais un commentaire : ancrée en début
    # de ligne, `#` exclu). Sinon → vraiment mort.
    base="$(basename "$script")"; base_re="${base//./\\.}"
    sourced_by=""
    while IFS= read -r gs; do
      [[ -z "$gs" || "$gs" == "$script" || ! -f "$gs" ]] && continue
      if grep -Eq "^[[:space:]]*(source|\.)[[:space:]]+[^#]*${base_re}([[:space:]\"'\`]|\$)" "$gs"; then
        sourced_by="$sourced_by $gs"
      fi
    done < <(printf '%s\n' "$SCRIPT_REFS" | awk '{print $2}' | sort -u)
    if [[ -n "$sourced_by" ]]; then
      ok "$script — lib sourcée par [$(echo $sourced_by | tr ' ' ',')] · non exécutée en propre (hors gate.needs, OK)"
    else
      report "$script — présent dans le dépôt mais lancé/sourcé par AUCUN job CI (gate mort)"
    fi
    continue
  fi
  # Chaque job qui lance ce script doit alimenter le gate.
  notgated=""
  for j in $jobs_for; do
    printf '%s\n' "$GATE_NEEDS" | grep -qx "$j" || notgated="$notgated $j"
  done
  if [[ -n "$notgated" ]]; then
    report "$script — lancé par [$(echo $jobs_for | tr ' ' ',')] mais job(s) hors gate.needs :$notgated"
  else
    ok "$script — lancé par [$(echo $jobs_for | tr ' ' ',')] · dans le gate"
  fi
done < <(git ls-files 'ci/*.sh' | grep -vx 'ci/check_ci_integrity.sh')

echo
if [[ "$FAIL" -eq 0 ]]; then
  echo -e "\033[32m✅ Câblage CI intègre (gate agrège tout job non-manuel · gates statiques verrouillés).\033[0m"
else
  echo -e "\033[31m❌ Dérive de câblage CI — corriger gate.needs / jobs avant merge.\033[0m"
fi
exit "$FAIL"
