#!/usr/bin/env bash
# tenant-offboard.sh -- full tenant offboarding (Option-3 or legacy single-svc layout).
# Usage: tenant-offboard.sh <client> [--audit|--apply]
#        tenant-offboard.sh --sweep-magnum-orphans [--audit|--apply]     (DOCFIX-099)
#   --audit (DEFAULT): read-only inventory of everything the offboard would remove.
#   --apply          : destructive. Requires typing the client name at the terminal
#                      (sweep mode: the literal token 'magnum-orphans').
# Order (dependency-driven): magnum clusters (as TENANT; cleans per-cluster trustee/trust/
# child app-cred) -> residual trust sweep (admin) -> LB/FIP/server sweep -> L3 unwind ->
# app creds (Phase E0: explicit inventory, idempotent -- they die with their user) ->
# users -> projects -> domain disable+delete -> local cred dir retired.
# --sweep-magnum-orphans: standalone mode; classifies every user in the 'magnum' trustee
# domain (D-046) as ORPHAN / PROTECTED / UNPROVEN and (--apply, gated) deletes ONLY
# proven orphans. Fail-closed: UNPROVEN is never touched. Uses exit codes 0/20/22/24.
# Exit: 0 ok | 20 precondition/blocklist | 21 cluster delete failed | 22 resource sweep failed
#       | 23 identity teardown failed | 24 confirmation refused/unavailable
# ERROR REGIME (DOCFIX-082): set -uo pipefail; every mutation goes through run() which
# CAPTURES output then TESTS exit status (no pipeline on the mutating command itself).
# Sweep phases are best-effort continue-and-report: failures are printed, counted, and the
# script exits nonzero at the end (22 sweep / 23 identity) rather than stranding silently.
set -uo pipefail
SDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"   # for helper .py (trust_filter.py)
. "$SDIR/lib-net.sh"                                    # KEYSTONE_VIP_DEFAULT (single source of truth)
SWEEP_FAIL=0; ID_FAIL=0
run(){ # run <failure-counter-name> <cmd...>: capture-then-test; count failures loudly
  local _c="$1"; shift; local out
  if out=$("$@" </dev/null 2>&1); then
    [ -n "$out" ] && printf '%s\n' "$out" | sed 's/^/    /'
  else
    printf '%s\n' "$out" | sed 's/^/    /'
    echo "    ^ FAILED: $*"
    eval "$_c=\$(( $_c + 1 ))"
  fi
  return 0
}
CLIENT="${1:-}"; MODE="${2:---audit}"
BLOCKLIST="default admin_domain service_domain magnum capi heat"
[ -n "$CLIENT" ] || { echo "usage: tenant-offboard.sh <client>|--sweep-magnum-orphans [--audit|--apply]"; exit 20; }
case "$MODE" in --audit|--apply) : ;; *) echo "bad mode: $MODE"; exit 20;; esac

admin_env(){ for v in $(env|awk -F= '/^OS_/{print $1}'); do unset "$v"; done; source "$HOME/admin-openrc"; }
is_id(){ [[ "${1:-}" =~ ^[0-9a-f]{32}$ ]]; }
row_ok(){ is_id "${1//-/}"; }   # keystone 32-hex OR dashed uuid row
# inv <var> <label> <cmd...> (DOCFIX-113): stdout-only inventory capture.
# Merged-stderr captures (2>&1) used to word-split warning text into the
# destructive loops -- a deprecation notice became delete argv. stderr goes to
# a tempfile and is SURFACED (first line), never merged or discarded.
# Returns the command's exit status; <var> receives stdout only.
inv(){
  local __v="$1" __label="$2"; shift 2
  local __e __o __rc; __e=$(mktemp)
  __o=$("$@" </dev/null 2>"$__e"); __rc=$?
  [ -s "$__e" ] && echo "  NOTE: $__label stderr: $(head -1 "$__e")"
  rm -f "$__e"
  printf -v "$__v" '%s' "$__o"
  return $__rc
}
# tinv <var> <label> <cmd...>: tenant-scoped variant of inv() -- runs the command
# under tenant_env in the capture subshell so magnum (tenant-scoped) calls carry
# tenant creds, while keeping the stdout-only discipline (Phase A cluster captures
# used to merge stderr 2>&1 -- a deprecation warning word-split into the delete
# loop / kept the wait loop from ever seeing empty -> false "not gone" exit 21).
tinv(){
  local __v="$1" __label="$2"; shift 2
  local __e __o __rc; __e=$(mktemp)
  __o=$( (tenant_env; "$@" </dev/null) 2>"$__e" ); __rc=$?
  [ -s "$__e" ] && echo "  NOTE: $__label stderr: $(head -1 "$__e")"
  rm -f "$__e"
  printf -v "$__v" '%s' "$__o"
  return $__rc
}

# ==== DOCFIX-099: orphaned per-cluster trustee sweep (magnum trustee domain) ====
# Magnum mints one trustee user per cluster in the 'magnum' trustee domain (D-046),
# conventionally named <cluster_uuid>_<project_id>, plus a trust and a child app-cred
# (D-039). An unclean cluster/tenant teardown can strand the trustee. Classification is
# FAIL-CLOSED: ORPHAN requires the owning project to be DEFINITIVELY gone (exact
# keystone not-found message). If the project is alive, only a SUCCESSFUL admin-scope
# cluster show proves PROTECTED; any cluster-show failure is UNPROVEN (magnum is
# tenant-scoped -- an admin-scope 404 can mask a policy denial). magnum_domain_admin
# and any name not matching the trustee convention are never touched. UNPROVEN never
# deletes. Trust list runs in the ADMIN context only (D-073 posture).
if [ "$CLIENT" = "--sweep-magnum-orphans" ]; then
  admin_env
  MDOM=$(openstack domain show magnum -f value -c id </dev/null 2>&1) || true
  is_id "$MDOM" || { echo "PRECOND: magnum trustee domain not found -- D-046 setup missing? (raw: $MDOM)"; exit 20; }
  MADM=$(openstack user show magnum_domain_admin --domain "$MDOM" -f value -c id </dev/null 2>&1) || true
  is_id "$MADM" || { echo "PRECOND: magnum_domain_admin not found in magnum domain (raw: $MADM)"; exit 20; }
  echo "=== MAGNUM ORPHAN-TRUSTEE SWEEP $MODE: domain=magnum ($MDOM) ==="
  MUSERS=$(openstack user list --domain "$MDOM" -f value -c ID -c Name </dev/null 2>&1) || true
  grep -q "^$MADM " <<<"$MUSERS" || { echo "PRECOND: magnum-domain user list did not include magnum_domain_admin -- listing untrusted (raw: $(head -1 <<<"$MUSERS"))"; exit 20; }
  # DOCFIX-094 pattern: stdout-only structured capture (merged stderr kills the parse)
  TRERR=$(mktemp)
  STR=$(openstack trust list -f json </dev/null 2>"$TRERR" | python3 "$SDIR/trust_filter.py" --fields ID,TRUSTEE) || true
  [ -s "$TRERR" ] && echo "  NOTE: trust list stderr: $(head -1 "$TRERR")"
  rm -f "$TRERR"
  grep -q 'trust-parse-error' <<<"${STR:-}" && { echo "PRECOND: trust list unparseable -- sweep would be blind on trusts"; exit 20; }
  UUID_RE='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
  ORPHANS=""; NORPH=0; NPROT=0; NUNPR=0
  while read -r TUID TNAME; do
    [ -n "$TUID" ] || continue
    if ! is_id "$TUID"; then
      echo "UNPROVEN  $TUID $TNAME -- not an id+name user row; listing suspect; not touching"; NUNPR=$((NUNPR+1)); continue
    fi
    if [ "$TUID" = "$MADM" ]; then
      echo "PROTECTED $TUID $TNAME -- trustee-domain service identity (D-046)"; NPROT=$((NPROT+1)); continue
    fi
    CU="${TNAME%%_*}"; PJ="${TNAME#*_}"
    if ! { [[ "$CU" =~ $UUID_RE ]] && [[ "$PJ" =~ ^[0-9a-f]{32}$ ]] && [ "$TNAME" = "${CU}_${PJ}" ]; }; then
      echo "UNPROVEN  $TUID $TNAME -- name does not match <cluster_uuid>_<project_id> trustee convention; not touching"; NUNPR=$((NUNPR+1)); continue
    fi
    PSHOW=$(openstack project show "$PJ" -f value -c id </dev/null 2>&1) || true
    if [ "$PSHOW" = "$PJ" ]; then
      CSHOW=$(openstack coe cluster show "$CU" -f value -c uuid </dev/null 2>&1) || true
      if [ "$CSHOW" = "$CU" ]; then
        echo "PROTECTED $TUID $TNAME -- cluster $CU alive in project $PJ"; NPROT=$((NPROT+1))
      else
        echo "UNPROVEN  $TUID $TNAME -- project alive but cluster $CU not shown in admin scope (a 404 can mask a policy denial; verify in tenant scope); not touching"; NUNPR=$((NUNPR+1))
      fi
    elif grep -q "No project with a name or ID of '$PJ' exists" <<<"$PSHOW"; then
      echo "ORPHAN    $TUID $TNAME -- owning project $PJ definitively gone"
      for T in $(awk -v u="$TUID" '$2==u{print $1}' <<<"${STR:-}"); do
        echo "          + trust $T (trustee is this user)"
      done
      ORPHANS="${ORPHANS}${TUID} ${TNAME}"$'\n'; NORPH=$((NORPH+1))
    else
      echo "UNPROVEN  $TUID $TNAME -- project lookup ambiguous (raw: $(head -1 <<<"$PSHOW")); not touching"; NUNPR=$((NUNPR+1))
    fi
  done <<<"$MUSERS"
  echo "--- sweep summary: ORPHAN=$NORPH PROTECTED=$NPROT UNPROVEN=$NUNPR (UNPROVEN is never deleted) ---"
  if [ "$MODE" = "--audit" ]; then
    echo "AUDIT COMPLETE (no changes). Re-run with: tenant-offboard.sh --sweep-magnum-orphans --apply"
    exit 0
  fi
  if [ "$NORPH" = 0 ]; then echo "NOTHING PROVEN ORPHANED -- no action taken (re-run safe)."; exit 0; fi
  if [ ! -r /dev/tty ]; then echo "REFUSED: --apply needs an interactive terminal"; exit 24; fi
  printf 'Type magnum-orphans to confirm DELETING %d orphaned trustee user(s): ' "$NORPH" > /dev/tty
  IFS= read -r CONF < /dev/tty || CONF=""
  [ "$CONF" = "magnum-orphans" ] || { echo "REFUSED: confirmation mismatch"; exit 24; }
  # NOT a pipeline loop: SWEEP_FAIL increments must survive (subshell would eat them)
  while read -r OU ON; do
    [ -n "$OU" ] || continue
    for T in $(awk -v u="$OU" '$2==u{print $1}' <<<"${STR:-}"); do
      echo "  trust delete $T (trustee $ON)"; run SWEEP_FAIL openstack trust delete "$T"
    done
    echo "  trustee user delete $OU ($ON)"; run SWEEP_FAIL openstack user delete "$OU"
    G=$(openstack user show "$OU" -f value -c id </dev/null 2>&1) || true
    [ "$G" = "$OU" ] && { echo "    ^ STILL PRESENT after delete"; SWEEP_FAIL=$((SWEEP_FAIL+1)); }
  done <<<"$ORPHANS"
  if [ "$SWEEP_FAIL" -gt 0 ]; then echo "SWEEP FINISHED WITH $SWEEP_FAIL FAILURES"; exit 22; fi
  echo "SWEEP COMPLETE: $NORPH orphaned trustee(s) removed."
  exit 0
fi

CL=$(printf '%s' "$CLIENT" | tr 'A-Z' 'a-z')
for b in $BLOCKLIST; do [ "$CL" = "$b" ] && { echo "REFUSED: '$CLIENT' is a protected domain"; exit 20; }; done

admin_env
DOM=$(openstack domain show "$CLIENT" -f value -c id </dev/null 2>&1) || true
is_id "$DOM" || { echo "PRECOND: domain '$CLIENT' not found (raw: $DOM)"; exit 20; }
echo "=== OFFBOARD $MODE: client=$CLIENT domain=$DOM ==="

# ---- tenant password cred discovery (magnum is tenant-scoped) ----
TCF=""
for c in "$HOME/tenant-${CLIENT}/${CLIENT}-cluster-cred.txt" \
         "$HOME/tenant-${CLIENT}/${CLIENT}-svc-cred.txt" \
         "$HOME/${CLIENT}-svc-cred.txt"; do
  [ -s "$c" ] && grep -qE '^password=' "$c" && { TCF="$c"; break; }
done
# DOCFIX-113: auth URL comes from the cred file's auth_url= line (same rule as
# tenant-acceptance.sh tenant_env); the literal is only a KEYSTONE_VIP-overridable
# fallback, consistent with tenant-onboard.sh.
KEYSTONE_VIP="${KEYSTONE_VIP:-$KEYSTONE_VIP_DEFAULT}"
tenant_env(){ for v in $(env|awk -F= '/^OS_/{print $1}'); do unset "$v"; done
  local AU; AU="$(awk -F= '/^auth_url=/{print $2}' "$TCF")"
  export OS_AUTH_URL="${AU:-https://${KEYSTONE_VIP}:5000/v3}" OS_IDENTITY_API_VERSION=3
  export OS_CACERT="${OS_CACERT:-$HOME/vault-init/vault-ca-root.pem}"
  export OS_USERNAME="$(awk -F= '/^username=/{print $2}' "$TCF")" OS_USER_DOMAIN_ID="$DOM"
  export OS_PROJECT_ID="$(awk -F= '/^project_id=/{print $2}' "$TCF")"
  export OS_PASSWORD="$(awk -F= '/^password=/{print $2}' "$TCF")"; }

# ---- inventory (DOCFIX-113: stdout-only; rows are shape-validated at each destructive use) ----
inv USERS "user list" openstack user list --domain "$DOM" -f value -c ID -c Name || true
inv PROJS "project list" openstack project list --domain "$DOM" -f value -c ID -c Name || true
echo "--- users in domain ---";    printf '%s\n' "${USERS:-<none>}" | sed 's/^/  /'
echo "--- projects in domain ---"; printf '%s\n' "${PROJS:-<none>}" | sed 's/^/  /'

CLUSTERS=""
if [ -n "$TCF" ]; then
  echo "--- clusters (tenant scope via $(basename "$TCF")) ---"
  tinv CLUSTERS "cluster list ($CLIENT)" openstack coe cluster list -f value -c uuid -c name -c status || true
  printf '%s\n' "${CLUSTERS:-<none>}" | sed 's/^/  /'
else
  echo "--- clusters: NO tenant password cred file found -- cannot enumerate (magnum is tenant-scoped) ---"
fi

UIDLIST=$(printf '%s\n' "$USERS" | awk '{print $1}')
# DOCFIX-094: stdout-only structured capture -- merged stderr (deprecation notices)
# used to land at char 0, kill the JSON parse, and silently EMPTY the trust sweep.
TRERR=$(mktemp)
TRUSTS=$( openstack trust list -f json </dev/null 2>"$TRERR" | python3 "$SDIR/trust_filter.py" --fields ID,TRUSTOR,TRUSTEE --trustor-in "$UIDLIST" ) || true
[ -s "$TRERR" ] && echo "  NOTE: trust list stderr: $(head -1 "$TRERR")"
rm -f "$TRERR"
grep -q 'trust-parse-error' <<<"${TRUSTS:-}" && { echo "PRECOND: trust list unparseable -- audit/sweep would be blind; fix before offboarding"; exit 20; }
echo "--- trusts with trustor in domain ---"; printf '%s\n' "${TRUSTS:-<none>}" | sed 's/^/  /'

for P in $(printf '%s\n' "$PROJS" | awk '{print $1}'); do
  echo "--- project $P resources ---"
  echo "  LBs:";     openstack loadbalancer list --project "$P" -f value -c id -c name -c provisioning_status </dev/null 2>&1 | sed 's/^/    /'
  echo "  FIPs:";    openstack floating ip list --project "$P" -f value -c ID -c "Floating IP Address" </dev/null 2>&1 | sed 's/^/    /'
  echo "  servers:"; openstack server list --project "$P" -f value -c ID -c Name -c Status </dev/null 2>&1 | sed 's/^/    /'
  echo "  routers:"; openstack router list --project "$P" -f value -c ID -c Name </dev/null 2>&1 | sed 's/^/    /'
  echo "  subnets:"; openstack subnet list --project "$P" -f value -c ID -c Name </dev/null 2>&1 | sed 's/^/    /'
  echo "  networks:"; openstack network list --project "$P" -f value -c ID -c Name </dev/null 2>&1 | sed 's/^/    /'
done
echo "--- local cred artifacts ---"
ls -d "$HOME/tenant-${CLIENT}" 2>/dev/null | sed 's/^/  /'
ls "$HOME/${CLIENT}"-*.txt "$HOME/${CLIENT}"-*.pem 2>/dev/null | sed 's/^/  /'

if [ "$MODE" = "--audit" ]; then echo "AUDIT COMPLETE (no changes). Re-run with --apply to execute."; exit 0; fi

# ---- confirmation gate (typed at terminal; refuses if no tty) ----
if [ ! -r /dev/tty ]; then echo "REFUSED: --apply needs an interactive terminal"; exit 24; fi
printf 'Type the client name (%s) to confirm DESTRUCTIVE offboard: ' "$CLIENT" > /dev/tty
IFS= read -r CONF < /dev/tty || CONF=""
[ "$CONF" = "$CLIENT" ] || { echo "REFUSED: confirmation mismatch"; exit 24; }

# ---- Phase A: clusters (as tenant) ----
if [ -n "$CLUSTERS" ] && [ "$CLUSTERS" != "<none>" ]; then
  [ -n "$TCF" ] || { echo "FATAL: clusters exist but no tenant cred"; exit 21; }
  printf '%s\n' "$CLUSTERS" | while read -r CU CN CS; do
    [ -n "$CU" ] || continue
    echo "== deleting cluster $CN ($CU, was $CS) =="
    (tenant_env; openstack coe cluster delete "$CU" </dev/null 2>&1) | sed 's/^/  /'
  done
  for i in $(seq 1 40); do
    if ! tinv LEFT "cluster wait ($CLIENT)" openstack coe cluster list -f value -c name -c status; then
      echo "  waiting: cluster list errored; retrying"; sleep 20; continue
    fi
    printf '%s [%02d] remaining: %s\n' "$(date +%T)" "$i" "${LEFT:-<none>}"
    [ -z "$LEFT" ] && break
    grep -q 'DELETE_FAILED' <<<"$LEFT" && { echo "FATAL: DELETE_FAILED -- see appendix-A stuck-delete"; exit 21; }
    sleep 20
  done
  tinv LEFT "cluster final ($CLIENT)" openstack coe cluster list -f value -c name \
    || { echo "FATAL: final cluster list failed -- cannot confirm clusters gone"; exit 21; }
  [ -z "$LEFT" ] || { echo "FATAL: clusters not gone in budget"; exit 21; }
fi

admin_env
# ---- Phase B: residual trusts ----
# NOT a pipeline loop (DOCFIX-113): the printf|while form ran the body in a
# subshell and ATE the run SWEEP_FAIL increments -- a failed trust delete
# exited 0. Here-string form, same as the sweep-mode loop above.
while read -r TID REST; do
  [ -n "$TID" ] && [ "$TID" != "<none>" ] || continue
  T2=$(openstack trust show "$TID" -f value -c id </dev/null 2>&1) || true
  [ "$T2" = "$TID" ] && { echo "  deleting residual trust $TID"; run SWEEP_FAIL openstack trust delete "$TID"; }
done <<<"${TRUSTS:-}"

# ---- Phase C+D: per-project resource sweep + L3 unwind ----
# DOCFIX-113 hardening: every inventory is a stdout-only capture (inv) and every
# row is shape-validated (row_ok) BEFORE it becomes delete argv, mirroring the
# sweep-mode fail-closed style. Invalid rows are SKIPPED and counted, never deleted.
for P in $(printf '%s\n' "$PROJS" | awk '{print $1}'); do
  inv LBS "loadbalancer list ($P)" openstack loadbalancer list --project "$P" -f value -c id \
    || { echo "    ^ FAILED: loadbalancer inventory ($P)"; SWEEP_FAIL=$((SWEEP_FAIL+1)); }
  while read -r LB; do
    [ -n "$LB" ] || continue
    row_ok "$LB" || { echo "  SKIP non-id LB row (listing suspect): $LB"; SWEEP_FAIL=$((SWEEP_FAIL+1)); continue; }
    echo "  LB delete --cascade $LB"; run SWEEP_FAIL openstack loadbalancer delete --cascade "$LB"
  done <<<"$LBS"
  # LB-gone wait (DOCFIX-113): stdout-only count (merged stderr used to count as
  # live LBs); a failed list or an exhausted budget FAILS the phase loudly instead
  # of falling through silently into the FIP/server deletes.
  LBWAIT=fail
  for i in $(seq 1 20); do
    if ! inv WLB "loadbalancer wait ($P)" openstack loadbalancer list --project "$P" -f value -c id; then
      echo "  waiting: LB list errored; retrying"; sleep 15; continue
    fi
    N=$(grep -c . <<<"$WLB" || true)
    [ "$N" = 0 ] && { LBWAIT=ok; break; }
    echo "  waiting LBs gone ($N left)"; sleep 15
  done
  [ "$LBWAIT" = ok ] || { echo "    ^ FAILED: LBs not confirmed gone in budget ($P) -- dependent sweeps may strand"; SWEEP_FAIL=$((SWEEP_FAIL+1)); }
  inv FIPS "floating ip list ($P)" openstack floating ip list --project "$P" -f value -c ID \
    || { echo "    ^ FAILED: floating ip inventory ($P)"; SWEEP_FAIL=$((SWEEP_FAIL+1)); }
  while read -r F; do
    [ -n "$F" ] || continue
    row_ok "$F" || { echo "  SKIP non-id FIP row (listing suspect): $F"; SWEEP_FAIL=$((SWEEP_FAIL+1)); continue; }
    echo "  FIP delete $F"; run SWEEP_FAIL openstack floating ip delete "$F"
  done <<<"$FIPS"
  inv SRVS "server list ($P)" openstack server list --project "$P" -f value -c ID \
    || { echo "    ^ FAILED: server inventory ($P)"; SWEEP_FAIL=$((SWEEP_FAIL+1)); }
  while read -r S; do
    [ -n "$S" ] || continue
    row_ok "$S" || { echo "  SKIP non-id server row (listing suspect): $S"; SWEEP_FAIL=$((SWEEP_FAIL+1)); continue; }
    echo "  server delete $S"; run SWEEP_FAIL openstack server delete "$S"
  done <<<"$SRVS"
  inv RTRS "router list ($P)" openstack router list --project "$P" -f value -c ID \
    || { echo "    ^ FAILED: router inventory ($P)"; SWEEP_FAIL=$((SWEEP_FAIL+1)); }
  inv SNS "subnet list ($P)" openstack subnet list --project "$P" -f value -c ID \
    || { echo "    ^ FAILED: subnet inventory ($P)"; SWEEP_FAIL=$((SWEEP_FAIL+1)); }
  while read -r R; do
    [ -n "$R" ] || continue
    row_ok "$R" || { echo "  SKIP non-id router row (listing suspect): $R"; SWEEP_FAIL=$((SWEEP_FAIL+1)); continue; }
    while read -r SN; do
      [ -n "$SN" ] || continue
      row_ok "$SN" || continue   # invalid subnet rows are counted once, in the delete loop below
      openstack router remove subnet "$R" "$SN" </dev/null 2>&1 || true
    done <<<"$SNS"
    openstack router unset --external-gateway "$R" </dev/null 2>&1 || true
    echo "  router delete $R"; run SWEEP_FAIL openstack router delete "$R"
  done <<<"$RTRS"
  while read -r SN; do
    [ -n "$SN" ] || continue
    row_ok "$SN" || { echo "  SKIP non-id subnet row (listing suspect): $SN"; SWEEP_FAIL=$((SWEEP_FAIL+1)); continue; }
    echo "  subnet delete $SN"; run SWEEP_FAIL openstack subnet delete "$SN"
  done <<<"$SNS"
  inv NWS "network list ($P)" openstack network list --project "$P" -f value -c ID \
    || { echo "    ^ FAILED: network inventory ($P)"; SWEEP_FAIL=$((SWEEP_FAIL+1)); }
  while read -r NW; do
    [ -n "$NW" ] || continue
    row_ok "$NW" || { echo "  SKIP non-id network row (listing suspect): $NW"; SWEEP_FAIL=$((SWEEP_FAIL+1)); continue; }
    echo "  network delete $NW"; run SWEEP_FAIL openstack network delete "$NW"
  done <<<"$NWS"
done

# ---- Phase E0: tenant app creds (explicit + idempotent; H2-style guard, DOCFIX-100) ----
# App creds die with their owning user (keystone cascade); this stage makes that removal
# EXPLICIT (per-user inventory, so the offboard record names what the user delete takes
# with it) and RE-RUN SAFE: a partially-offboarded tenant whose users are already gone
# (e.g. a prior --apply cleared Phase E users but failed at the domain step) SKIPs
# cleanly instead of erroring -- same skip semantics as tenant-onboard.sh stage3 (H2).
UIDS_E=$(printf '%s\n' "${USERS:-}" | awk 'NF{print $1}')
if [ -z "$UIDS_E" ]; then
  echo "  app-cred stage: no users remain in domain -- SKIP (idempotent re-run)"
else
  for U in $UIDS_E; do
    ACOUT=$(openstack application credential list --user "$U" -f value -c ID </dev/null 2>&1); ACRC=$?
    if [ "$ACRC" != 0 ]; then
      if grep -q "No user with a name or ID" <<<"$ACOUT"; then
        echo "  app creds ($U): owner already gone -- SKIP (idempotent re-run)"
      elif grep -qiE "identity:list_application_credentials|not authorized to perform the requested action.*application|HTTP 403" <<<"$ACOUT"; then
        # DOCFIX-126: admin CANNOT list another user's application credentials --
        # base policy identity:list_application_credentials is admin_or_owner, and
        # admin is not the owner (measured DOCFIX-095). This 403 is EXPECTED, not a
        # failure: the app creds cascade-delete with their owning user regardless.
        # The inventory here is best-effort; do NOT count this as an identity
        # failure (it previously FATAL'd at exit 23 before Phase F cred retirement).
        echo "  app creds ($U): cannot inventory as admin (not owner; base policy) -- creds cascade on user delete, OK"
      else
        printf '%s\n' "$ACOUT" | sed 's/^/    /'
        echo "    ^ FAILED: application credential list --user $U"
        ID_FAIL=$((ID_FAIL+1))
      fi
      continue
    fi
    if [ -z "$ACOUT" ]; then
      echo "  app creds ($U): none -- already clean"
    else
      for AC in $ACOUT; do echo "  app cred $AC (owner $U) -- removed via owner delete (cascade)"; done
    fi
  done
fi

# ---- Phase E: identities (rows shape-validated before delete; DOCFIX-113) ----
while read -r U UNAME; do
  [ -n "$U" ] || continue
  is_id "$U" || { echo "  SKIP non-id user row (listing suspect): $U $UNAME"; ID_FAIL=$((ID_FAIL+1)); continue; }
  echo "  user delete $U"; run ID_FAIL openstack user delete "$U"
done <<<"${USERS:-}"
while read -r PJ PJNAME; do
  [ -n "$PJ" ] || continue
  is_id "$PJ" || { echo "  SKIP non-id project row (listing suspect): $PJ $PJNAME"; ID_FAIL=$((ID_FAIL+1)); continue; }
  echo "  project delete $PJ"; run ID_FAIL openstack project delete "$PJ"
done <<<"${PROJS:-}"
run ID_FAIL openstack domain set --disable "$DOM"
echo "  domain delete $DOM"; run ID_FAIL openstack domain delete "$DOM"
D2=$(openstack domain show "$CLIENT" -f value -c id </dev/null 2>&1) || true
is_id "$D2" && { echo "FATAL: domain still present"; exit 23; }
[ "$ID_FAIL" = 0 ] || { echo "FATAL: identity teardown had $ID_FAIL failures (domain gone; review output)"; exit 23; }

# ---- Phase F: retire local creds ----
if [ -d "$HOME/tenant-${CLIENT}" ]; then
  DST="$HOME/tenant-${CLIENT}.offboarded-$(date -u +%Y%m%d-%H%M%S)"
  mv "$HOME/tenant-${CLIENT}" "$DST" && chmod 700 "$DST" && echo "  local creds retired -> $DST (shred manually when record no longer needed)"
fi
ls "$HOME/${CLIENT}"-*.txt "$HOME/${CLIENT}"-*.pem 2>/dev/null | sed 's/^/  LEGACY loose file (handle manually): /'
if [ "$SWEEP_FAIL" -gt 0 ]; then
  echo "OFFBOARD FINISHED WITH $SWEEP_FAIL SWEEP FAILURES: $CLIENT (identities gone; orphaned resources above)"
  exit 22
fi
echo "OFFBOARD COMPLETE: $CLIENT"
exit 0
