#!/usr/bin/env bash
# Self-test for ci-notify.sh's needs classification, scope and attribution.
#
# WHY THIS EXISTS. The notifier folded `failure` and `cancelled` into one bucket
# and rendered FAILURE for either, so an operator-cancelled golden run paged the
# fleet as a red (releases#107). Splitting the classes is a two-line change; the
# reason it needs a rig is the PRECEDENCE. A genuine failure normally CANCELS its
# siblings, so mixed failure+cancelled is the shape of a REAL red — and the
# obvious "cancelled wins" reading of the split would silence exactly the runs
# this informant exists to announce.
#
# So the matrix below is not "does cancelled go quiet". Half of it is the
# POSITIVE CONTROL: a real failure still pages, on its own AND when cancelled
# siblings are standing next to it. A rig that only proved the quiet half would
# pass just as happily against a notifier that had been silenced outright.
#
# Classification/body checks use SPT_CI_NOTIFY_LIB. The entrypoint cases below
# substitute only git and spt at the process boundary: no daemon, live recipients
# or subnet, but the actual notification body, routing and diagnostics are exercised.
#
# Run: bash .github/ci/ci-notify-selftest.sh
# [unit->REQ-CI-NOTIFY-CANCELLED-CLASS]
set -uo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Fixed context so body assertions are deterministic regardless of the caller's
# environment (the real script reads these from the workflow).
export GITHUB_REPOSITORY="BigscreenVR/spt-bs-core"
export GITHUB_REF_NAME="golden/selftest"
export GITHUB_SHA="0123456789abcdef0123456789abcdef01234567"
export GITHUB_SERVER_URL="https://github.com"
export GITHUB_RUN_ID="99999999"
export PR_NUMBER=""
export SPT_CI_NOTIFY_LIB=1
# shellcheck source=./ci-notify.sh
. "$HERE/ci-notify.sh"

fails=0
checks=0

ok() {
  checks=$((checks + 1))
  if [ "$2" = "$3" ]; then
    printf '  ok   %-58s %s\n' "$1" "$2"
  else
    printf '  FAIL %-58s got %-10s want %s\n' "$1" "$2" "$3"
    fails=$((fails + 1))
  fi
}

# Drives one row of the matrix. Args are the six job results in the order the
# real script checks them; "" means the job never ran.
scenario() {
  RESULT_CHANGES="$1"
  RESULT_TRACEABILITY="$2"
  RESULT_TEST="$3"
  RESULT_N1_GATE="$4"
  RESULT_TWOHOST_A="$5"
  RESULT_TWOHOST_B="$6"
  compute_verdict
}

# --- the verdict matrix ----------------------------------------------------

scenario success success success success success success
ok "all-success -> SUCCESS" "$verdict" SUCCESS
ok "all-success lists nothing failing" "${failing:-<empty>}" "<empty>"
ok "all-success lists nothing cancelled" "${cancelled:-<empty>}" "<empty>"

scenario success success failure success success success
ok "single-failure -> FAILURE" "$verdict" FAILURE
ok "single-failure names the job" "$failing" "test(failure)"

scenario cancelled cancelled cancelled cancelled cancelled cancelled
ok "all-cancelled -> CANCELLED" "$verdict" CANCELLED
ok "all-cancelled lists nothing failing" "${failing:-<empty>}" "<empty>"

# THE PRECEDENCE ROW. This is the shape of a real red: one job failed and the
# rest were cancelled out from under it. Reading it as a cancellation is the
# regression this whole file guards.
scenario success success failure cancelled cancelled cancelled
ok "failure+cancelled -> FAILURE (precedence)" "$verdict" FAILURE
ok "failure+cancelled still names the failure" "$failing" "test(failure)"
ok "failure+cancelled keeps siblings as context" "$cancelled" "n1-gate, twohost-a, twohost-b"

# A single cancelled job among successes is still the cancelled class — the
# verdict is not a majority vote.
scenario success success success cancelled success success
ok "one-cancelled -> CANCELLED" "$verdict" CANCELLED

# REQ-CI-DOCS-ONLY-THIN: a Markdown-only PR skips `test` and `n1-gate` by
# design. Skipped is not red, and it does not mask a cancellation either.
scenario success success skipped skipped "" ""
ok "docs-only skips -> SUCCESS" "$verdict" SUCCESS
scenario success success skipped skipped cancelled ""
ok "skipped alongside cancelled -> CANCELLED" "$verdict" CANCELLED
scenario success success skipped skipped failure ""
ok "skipped alongside failure -> FAILURE" "$verdict" FAILURE

# Jobs that never ran arrive as "" and are not a verdict of their own.
scenario "" "" "" "" "" ""
ok "no jobs ran -> SUCCESS" "$verdict" SUCCESS

# --- body wording ----------------------------------------------------------
# The requirement is not merely a different variable: the CANCELLED body must
# not read as a red to a human skimming it. Asserted as a pair so the predicate
# is proven able to fail — "body does not contain FAILURE" is worth nothing
# without a body that does.

scenario cancelled cancelled cancelled cancelled cancelled cancelled
cancelled_body="$(compose_body)"
case "$cancelled_body" in
  *FAILURE*) ok "CANCELLED body avoids the word FAILURE" "contains" "absent" ;;
  *) ok "CANCELLED body avoids the word FAILURE" "absent" "absent" ;;
esac
case "$cancelled_body" in
  "CI NEEDS CANCELLED"*) ok "CANCELLED headline scopes its class to needs" "yes" "yes" ;;
  *) ok "CANCELLED headline scopes its class to needs" "no" "yes" ;;
esac
case "$cancelled_body" in
  *"cancelled: changes, traceability, test, n1-gate, twohost-a, twohost-b"*)
    ok "CANCELLED body lists the cancelled jobs" "yes" "yes" ;;
  *) ok "CANCELLED body lists the cancelled jobs" "no" "yes" ;;
esac

# The positive control for the predicate directly above.
scenario success success failure cancelled cancelled cancelled
failure_body="$(compose_body)"
case "$failure_body" in
  *FAILURE*) ok "FAILURE body DOES contain the word FAILURE" "contains" "contains" ;;
  *) ok "FAILURE body DOES contain the word FAILURE" "absent" "contains" ;;
esac
case "$failure_body" in
  *"failing: test(failure)"*) ok "FAILURE body names the failing job" "yes" "yes" ;;
  *) ok "FAILURE body names the failing job" "no" "yes" ;;
esac
case "$failure_body" in
  *"cancelled: n1-gate, twohost-a, twohost-b"*) ok "FAILURE body carries cancelled context" "yes" "yes" ;;
  *) ok "FAILURE body carries cancelled context" "no" "yes" ;;
esac

# Clean supplied needs carry neither adverse-result list.
scenario success success success success success success
success_body="$(compose_body)"
case "$success_body" in
  *"failing:"* | *"cancelled:"*) ok "SUCCESS body carries neither list" "no" "yes" ;;
  *) ok "SUCCESS body carries neither list" "yes" "yes" ;;
esac

# --- the seam itself -------------------------------------------------------
# SPT_CI_NOTIFY_LIB must refuse EXECUTION, or a caller that meant to source
# would get a silent exit-0 no-op that looks exactly like a successful run.
seam_error="$(SPT_CI_NOTIFY_LIB=1 bash "$HERE/ci-notify.sh" 2>&1)"
ok "LIB flag refuses execution (exit 2)" "$?" "2"
case "$seam_error" in
  *"for SOURCING"*) ok "LIB misuse explains the required invocation" "yes" "yes" ;;
  *) ok "LIB misuse explains the required invocation" "no" "yes" ;;
esac

# --- entrypoint: emitted messages, attribution and best-effort errors --------
# IR-40 arm B / IR-47. Keep the real parser (not git's trailer tokenizer) and
# send loop in the exercised path. The mocks cannot send to a real endpoint.
tmp="$(mktemp -d)" || exit 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin" || exit 1
cat >"$tmp/bin/git" <<'MOCK'
#!/usr/bin/env bash
if [ "${MOCK_GIT_RC:-0}" -ne 0 ]; then
  echo "git: fixture cannot read HEAD" >&2
  exit "$MOCK_GIT_RC"
fi
printf '%s\n' "$MOCK_COMMIT_BODY"
MOCK
cat >"$tmp/bin/spt" <<'MOCK'
#!/usr/bin/env bash
if [ "${1:-} ${2:-}" = "daemon status" ]; then
  exit 1
fi
if [ "${1:-}" = "send" ]; then
  printf '%s\n' "$2" >>"$MOCK_SEND_LOG"
  cat >"$MOCK_BODY_DIR/$2"
  if [ "${MOCK_SEND_RC:-0}" -ne 0 ]; then
    echo "spt: fixture send refused" >&2
    exit "$MOCK_SEND_RC"
  fi
  echo "QUEUED"
  exit 0
fi
exit 2
MOCK
chmod +x "$tmp/bin/git" "$tmp/bin/spt" || exit 1

# Args: label, commit body, recipients, attribution warning yes/no, test result,
# optional git/send exit codes. Read the bytes supplied to spt, not compose_body.
# [unit->REQ-CI-NOTIFY-SCOPE]
# [unit->REQ-CI-NOTIFY-ATTRIBUTION]
notify_case() {
  local label="$1" output rc recipients saw_warning emitted expected_class
  : >"$tmp/sends"
  rm -f "$tmp/doyle" "$tmp/hertz"
  output="$(
    PATH="$tmp/bin:$PATH" \
    SPT_CI_NOTIFY_LIB="" \
    MOCK_COMMIT_BODY="$2" \
    MOCK_GIT_RC="${6:-0}" \
    MOCK_SEND_RC="${7:-0}" \
    MOCK_SEND_LOG="$tmp/sends" \
    MOCK_BODY_DIR="$tmp" \
    NOTIFY_SSH_HOP="" \
    RESULT_CHANGES=success \
    RESULT_TRACEABILITY=success \
    RESULT_TEST="$5" \
    RESULT_N1_GATE=skipped \
    RESULT_TWOHOST_A="" \
    RESULT_TWOHOST_B="" \
    GITHUB_JOB=notify \
    RUNNER_NAME=selftest \
    bash "$HERE/ci-notify.sh" 2>&1
  )"
  rc=$?
  ok "$label remains non-fatal" "$rc" 0
  recipients="$(paste -sd ' ' "$tmp/sends")"
  ok "$label recipients" "$recipients" "$3"
  case "$output" in
    *"::warning title=CI notification attribution missing::"*) saw_warning=yes ;;
    *) saw_warning=no ;;
  esac
  ok "$label attribution warning" "$saw_warning" "$4"
  emitted="$(cat "$tmp/doyle")"
  case "$5" in
    failure) expected_class=FAILURE ;;
    cancelled) expected_class=CANCELLED ;;
    *) expected_class=SUCCESS ;;
  esac
  case "$emitted" in
    "CI NEEDS $expected_class"*) ok "$label emitted verdict is needs-scoped" yes yes ;;
    *) ok "$label emitted verdict is needs-scoped" no yes ;;
  esac
  case "$emitted" in
    *"scope:"*"supplied needs"*"notifying job excluded"*)
      ok "$label identifies its evidence boundary" yes yes ;;
    *) ok "$label identifies its evidence boundary" no yes ;;
  esac
  case "$emitted" in
    *"run conclusion: not observed"*)
      ok "$label does not claim a run conclusion" yes yes ;;
    *) ok "$label does not claim a run conclusion" no yes ;;
  esac
  case "$3" in
    *hertz*) ok "$label co-author receives the same body" "$(cat "$tmp/hertz")" "$emitted" ;;
  esac
  if [ "${6:-0}" -ne 0 ]; then
    case "$output" in
      *"git: fixture cannot read HEAD"*"Could not read or parse head commit"*)
        ok "$label preserves read failure diagnostic" yes yes ;;
      *) ok "$label preserves read failure diagnostic" no yes ;;
    esac
  fi
  if [ "${7:-0}" -ne 0 ]; then
    case "$output" in
      *"SEND FAILED rc=$7: spt: fixture send refused"*)
        ok "$label preserves send failure diagnostic" yes yes ;;
      *) ok "$label preserves send failure diagnostic" no yes ;;
    esac
  fi
}

notify_case valid $'subject\n\nCo-authored by: hertz' 'doyle hertz' no success
# An absent test result cannot become evidence of a completed run.
notify_case partial-needs $'subject\n\nCo-authored by: hertz' 'doyle hertz' no ''
# Legacy parser: case-insensitive key, last matching line, whitespace trimming
# and optional email suffix are all supported; do not broaden to hyphenated keys.
notify_case legacy $'subject\n\nCo-authored by: ignored\ncO-aUTHORED BY: \thertz  <agent@example.test>\r' 'doyle hertz' no cancelled
notify_case doyle $'subject\n\nCo-authored by: doyle' doyle no failure
notify_case missing 'subject only' doyle yes success
notify_case literal-newlines 'subject\n\nCo-authored by: hertz' doyle yes success
notify_case hyphenated $'subject\n\nCo-authored-by: hertz' doyle yes success
notify_case empty-name $'subject\n\nCo-authored by: <agent@example.test>' doyle yes success
notify_case unreadable-head '' doyle yes success 128
notify_case send-failed $'subject\n\nCo-authored by: hertz' 'doyle hertz' no success 0 17

# --- result ----------------------------------------------------------------

if [ "$fails" -ne 0 ]; then
  echo "ci-notify-selftest: $fails/$checks assertions FAILED" >&2
  exit 1
fi
echo "ci-notify-selftest: $checks/$checks assertions passed"
