#!/bin/bash
# perf-onboarding-ledger-write-gate.sh — schema + state-machine integrity
#                                        gate for writes to the perf
#                                        onboarding status ledger.
#
# Hook    : PreToolUse:Write|Edit
# Mode    : DENY (blocks the write before it lands on disk)
# State   : validates shape via hooks/lib/validator.bundle.mjs (the
#           perf-onboarding-status schema is inlined in the bundle at build
#           time; source schemas/perf-onboarding-status.schema.json), plus
#           the pre-existing tests/perf/docs/perf-onboarding-status.json
#           (when present) to validate the state-machine transition.
# Env     : none
#
# Why
# ---
# The ledger is the single source of truth for the perf pipeline state. A
# corrupted ledger silently degrades every downstream gate. This hook is
# the guard at the only mutation point.
#
# What it gates
# -------------
# 1. **Shape validation.** Proposed contents must validate against the
#    perf-onboarding-status schema inlined in hooks/lib/validator.bundle.mjs.
# 2. **No phase-skip transitions.**
# 3. **No reviewerVerdict: approved without a handoverEnvelope.**
# 4. **Actor-identity on approval transitions** (separation of duties).
# 5. **Mode authorisation** (runMode/modeAuthorizer co-location).
# 6. **Silent-allow for non-ledger writes** (only acts on perf ledger path).
# 7. **Per-phase positive-deliverable checks** at phase → completed transitions.
#
# Canonical reference
# -------------------
# schemas/perf-onboarding-status.schema.json
# skills/perf-onboarding/SKILL.md §"Status ledger + workflow reviewer"
# hooks/lib/validator.bundle.mjs (generated by `npm run build:validator`)
#
# Failure → action
# ----------------
# Shape invalid                      → DENY with schema-path + bad field
# Phase-skip without approval        → DENY naming the missing in-between phase
# reviewerVerdict approved w/o handover → DENY naming the empty field

set -uo pipefail

JQ="$(dirname "${BASH_SOURCE[0]}")/bin/jq"
[ -x "$JQ" ] || JQ="$(command -v jq || true)"
if [ -z "$JQ" ]; then
  echo "[$(basename "${BASH_SOURCE[0]}")] FATAL: jq not found at \$HOOK_DIR/bin/jq nor on PATH." >&2
  exit 1
fi

INPUT=$(cat)

# Session-scope gate: this hook applies only to achilles-activated
# sessions; plain dev sessions silent-allow (lib/achilles-activation.sh).
. "$(dirname "${BASH_SOURCE[0]}")/lib/achilles-activation.sh"
achilles_require_active "$INPUT"
TOOL_NAME=$(echo "$INPUT" | "$JQ" -r '.tool_name // empty' 2>/dev/null || echo "")

# Only act on Write and Edit.
case "$TOOL_NAME" in
  Write|Edit) ;;
  *) exit 0 ;;
esac

FILE_PATH=$(echo "$INPUT" | "$JQ" -r '.tool_input.file_path // empty' 2>/dev/null || echo "")

# Silent-allow when this isn't the perf ledger. Normalise leading slash so
# bare relative paths also match.
NORM_PATH="/${FILE_PATH#/}"
case "$NORM_PATH" in
  */tests/perf/docs/perf-onboarding-status.json) ;;
  *) exit 0 ;;
esac

# shellcheck disable=SC1091
. "$(dirname "${BASH_SOURCE[0]}")/lib/pipeline-gate.sh"
PIPELINE_LEDGER="$FILE_PATH"
PIPELINE_SIDECAR="$(dirname "$FILE_PATH")/.ledger-integrity.json"
PIPELINE_SCHEMA_NAME="perf-onboarding-status"
PIPELINE_MSG_LEDGER_NAME='perf-onboarding-status.json'
PIPELINE_MSG_SIDECAR_REL='tests/perf/docs/.ledger-integrity.json'
PIPELINE_MSG_LEDGER_REL='tests/perf/docs/perf-onboarding-status.json'
PIPELINE_MSG_REVIEWER_LABEL='perf-reviewer-phase'
PIPELINE_MSG_SKILL_REF='skills/perf-onboarding/SKILL.md'
PIPELINE_MSG_SCHEMA_REF='schemas/perf-onboarding-status.schema.json'
PIPELINE_MSG_REVIEWER_SKILL='skills/workflow-reviewer/SKILL.md'

emit_deny() {
  local reason="$1"
  "$JQ" -n --arg r "$reason$(achilles_scope_notice)" '{
    "hookSpecificOutput": {
      "hookEventName": "PreToolUse",
      "permissionDecision": "deny",
      "permissionDecisionReason": $r
    }
  }'
}

# Extract the proposed contents. For Write the field is `content`; for
# Edit we synthesise by applying the patch to the existing file (via the
# validator bundle's `replace` subcommand).
PROPOSED_CONTENT=""
case "$TOOL_NAME" in
  Write)
    PROPOSED_CONTENT=$(echo "$INPUT" | "$JQ" -r '.tool_input.content // empty' 2>/dev/null || echo "")
    ;;
  Edit)
    OLD_STRING=$(echo "$INPUT" | "$JQ" -r '.tool_input.old_string // empty' 2>/dev/null || echo "")
    NEW_STRING=$(echo "$INPUT" | "$JQ" -r '.tool_input.new_string // ""' 2>/dev/null || echo "")
    REPLACE_ALL=$(echo "$INPUT" | "$JQ" -r '.tool_input.replace_all // false' 2>/dev/null || echo "false")
    if [ -f "$FILE_PATH" ] && [ -n "$OLD_STRING" ]; then
      NODE_BIN="$(command -v node 2>/dev/null || true)"
      VALIDATOR="$(dirname "${BASH_SOURCE[0]}")/lib/validator.bundle.mjs"
      if [ -z "$NODE_BIN" ] || [ ! -f "$VALIDATOR" ]; then
        emit_deny "[BLOCKED] Cannot synthesise the proposed ledger content for an Edit (node or the validator bundle is unavailable), so the gate cannot validate the transition.

File: ${FILE_PATH}

Fix: re-issue this change as a full Write of the complete ledger JSON
(the Write path validates without content synthesis), or restore node /
reinstall @civitas-cerebrum/achilles to get hooks/lib/validator.bundle.mjs."
        exit 0
      fi
      TMP_OLD=$(mktemp /tmp/perf-ledger-old-XXXXXX) ; TMP_NEW=$(mktemp /tmp/perf-ledger-new-XXXXXX)
      printf '%s' "$OLD_STRING" > "$TMP_OLD"
      printf '%s' "$NEW_STRING" > "$TMP_NEW"
      ALL_FLAG=""
      [ "$REPLACE_ALL" = "true" ] && ALL_FLAG="--all"
      SYNTH_EXIT=0
      SYNTH_ERR_FILE=$(mktemp /tmp/perf-ledger-synth-err-XXXXXX)
      PROPOSED_CONTENT=$("$NODE_BIN" "$VALIDATOR" replace "$FILE_PATH" "$TMP_OLD" "$TMP_NEW" $ALL_FLAG 2>"$SYNTH_ERR_FILE") || SYNTH_EXIT=$?
      SYNTH_ERR=$(cat "$SYNTH_ERR_FILE" 2>/dev/null || true)
      rm -f "$TMP_OLD" "$TMP_NEW" "$SYNTH_ERR_FILE"
      if [ "$SYNTH_EXIT" != "0" ]; then
        emit_deny "[BLOCKED] Edit to perf-onboarding-status.json could not be synthesised: ${SYNTH_ERR:-unknown error}.

File: ${FILE_PATH}

The gate validates the post-edit content before allowing the write. An
old_string that is missing or not unique would also fail the Edit tool
itself. Fix the old_string (or use replace_all) and re-issue."
        exit 0
      fi
    fi
    ;;
esac

# Silent-allow when we couldn't extract content (empty Write or Edit vs missing file).
[ -n "$PROPOSED_CONTENT" ] || exit 0

TMP_PROPOSED=$(mktemp /tmp/perf-onboarding-ledger-XXXXXX.json)
trap 'rm -f "$TMP_PROPOSED"' EXIT
printf '%s' "$PROPOSED_CONTENT" > "$TMP_PROPOSED"

NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || true)}"
VALIDATOR="$(dirname "${BASH_SOURCE[0]}")/lib/validator.bundle.mjs"

pipeline_schema_validate "$TMP_PROPOSED" "$FILE_PATH"
_psv_ret=$?
[ "$_psv_ret" -eq 0 ] && exit 0

# When schema validation was skipped (no node / no ajv), still enforce JSON
# parseability.
if [ "${PIPELINE_SCHEMA_VALIDATION_SKIPPED:-0}" = "1" ]; then
  if ! "$JQ" -e . "$TMP_PROPOSED" >/dev/null 2>&1; then
    emit_deny "[BLOCKED] Proposed perf-onboarding-status.json is not parseable JSON (schema validation was skipped because node/ajv is unavailable, but jq parsing failed).

File: ${FILE_PATH}

Fix: re-author the JSON, run \`jq . <<< '<contents>'\` locally to confirm it parses, then re-issue the write."
    exit 0
  fi
fi

# ---------------------------------------------------------------------------
# State-machine transition check (lib call).
# ---------------------------------------------------------------------------
pipeline_validate_transition "$TMP_PROPOSED" "$FILE_PATH" && exit 0

# ---------------------------------------------------------------------------
# Actor-identity check on approval transitions — separation of duties.
# ---------------------------------------------------------------------------
AGENT_ID=$(echo "$INPUT" | "$JQ" -r '.agent_id // empty' 2>/dev/null || echo "")
pipeline_check_sod "$TMP_PROPOSED" "$FILE_PATH" "$AGENT_ID" && exit 0

# ---------------------------------------------------------------------------
# Mode-authorisation check.
# ---------------------------------------------------------------------------
pipeline_check_mode_authorizer "$TMP_PROPOSED" "$FILE_PATH" && exit 0

# ---------------------------------------------------------------------------
# Per-phase positive-deliverable checks for the perf pipeline.
# When a phase's status flips to "completed" in the proposed write, the
# canonical deliverables for that phase must already exist on disk.
#
# Per-phase manifests:
#   Phase 1 (Scaffold)       — tests/perf/perf-onboarding.config.json +
#                              tests/perf/lib/ is non-empty
#   Phase 2 (Readiness)      — tests/perf/docs/readiness.md
#   Phase 3 (Scenario-model) — tests/perf/docs/scenario-model.md + sentinel
#                              `<!-- perf-onboarding:scenario-model -->` on
#                              line 1 + ≥1 file under tests/perf/scenarios/*.js
#   Phase 4 (Baseline)       — ≥1 file under tests/perf/baselines/*.json
#   Phase 5 (Load-run)       — ≥1 file under tests/perf/results/*.json
#   Phase 6 (Threshold-gate) — tests/perf/docs/threshold-verdict.json +
#                              jq -e '.deliberateBreach | length > 0' passes
#   Phase 7 (Report)         — tests/perf/docs/perf-report.md + line 1 is
#                              `<!-- perf-onboarding:report -->`
# ---------------------------------------------------------------------------

# PROJECT_ROOT is the directory containing tests/perf/docs/.
PROJECT_ROOT="${FILE_PATH%/tests/perf/docs/perf-onboarding-status.json}"

# Build the set of phase IDs whose status is transitioning to "completed".
PHASES_NEWLY_COMPLETED=""
for phase_id in 1 2 3 4 5 6 7; do
  idx=$((phase_id - 1))
  new_status=$("$JQ" -r ".phases[${idx}].status // empty" "$TMP_PROPOSED" 2>/dev/null || echo "")
  prior_status="pending"
  if [ -f "$FILE_PATH" ]; then
    prior_status=$("$JQ" -r ".phases[${idx}].status // \"pending\"" "$FILE_PATH" 2>/dev/null || echo "pending")
  fi
  if [ "$new_status" = "completed" ] && [ "$prior_status" != "completed" ]; then
    PHASES_NEWLY_COMPLETED="${PHASES_NEWLY_COMPLETED} ${phase_id}"
  fi
done

# Helper: emit a deny for a perf deliverable check failure.
emit_phase_deny() {
  local phase="$1"
  local missing="$2"
  local fix_hint="$3"
  local skill_ref="$4"
  emit_deny "[BLOCKED] Perf Phase ${phase} cannot transition to status: \"completed\" — required deliverable missing.

File: ${FILE_PATH}

Missing: ${missing}

This is the per-phase positive-deliverable check. The perf ledger cannot
mark a phase complete unless that phase's canonical deliverables exist
on disk.

Fix: ${fix_hint}

See: ${skill_ref}"
  exit 0
}

perf_check_deliverables() {
  local phase_id="$1"
  case "$phase_id" in
    1)
      # Phase 1 (Scaffold): config file + non-empty lib/ directory.
      CONFIG_PATH="$PROJECT_ROOT/tests/perf/perf-onboarding.config.json"
      LIB_DIR="$PROJECT_ROOT/tests/perf/lib"
      if [ ! -f "$CONFIG_PATH" ]; then
        emit_phase_deny "1 (Scaffold)" \
          "tests/perf/perf-onboarding.config.json does not exist." \
          "complete the scaffold phase: create perf-onboarding.config.json with baseline targets, VU limits, and threshold definitions." \
          "skills/perf-onboarding/SKILL.md §\"Phase 1 — Scaffold\""
      fi
      # lib/ must be a non-empty directory.
      LIB_HAS_FILES="false"
      if [ -d "$LIB_DIR" ]; then
        LIB_FILE_COUNT=$(find "$LIB_DIR" -maxdepth 2 -type f 2>/dev/null | wc -l | tr -d ' ')
        [ "${LIB_FILE_COUNT:-0}" -gt 0 ] && LIB_HAS_FILES="true"
      fi
      if [ "$LIB_HAS_FILES" != "true" ]; then
        emit_phase_deny "1 (Scaffold)" \
          "tests/perf/lib/ does not exist or is empty." \
          "populate tests/perf/lib/ with shared k6 helper modules (e.g. auth.js, thresholds.js) before closing Phase 1." \
          "skills/perf-onboarding/SKILL.md §\"Phase 1 — Scaffold\""
      fi
      ;;
    2)
      # Phase 2 (Readiness): readiness.md.
      READINESS_PATH="$PROJECT_ROOT/tests/perf/docs/readiness.md"
      if [ ! -f "$READINESS_PATH" ]; then
        emit_phase_deny "2 (Readiness)" \
          "tests/perf/docs/readiness.md does not exist." \
          "complete the readiness assessment and write the readiness document before closing Phase 2." \
          "skills/perf-onboarding/SKILL.md §\"Phase 2 — Readiness\""
      fi
      ;;
    3)
      # Phase 3 (Scenario-model): scenario-model.md + sentinel + ≥1 scenario file.
      SCENARIO_MODEL_PATH="$PROJECT_ROOT/tests/perf/docs/scenario-model.md"
      if [ ! -f "$SCENARIO_MODEL_PATH" ]; then
        emit_phase_deny "3 (Scenario-model)" \
          "tests/perf/docs/scenario-model.md does not exist." \
          "author the scenario model document before closing Phase 3." \
          "skills/perf-onboarding/SKILL.md §\"Phase 3 — Scenario-model\""
      fi
      SCENARIO_FIRST_LINE=$(head -n 1 "$SCENARIO_MODEL_PATH" 2>/dev/null || echo "")
      if [ "$SCENARIO_FIRST_LINE" != "<!-- perf-onboarding:scenario-model -->" ]; then
        emit_phase_deny "3 (Scenario-model)" \
          "tests/perf/docs/scenario-model.md is missing the line-1 sentinel \`<!-- perf-onboarding:scenario-model -->\`. Got: \"${SCENARIO_FIRST_LINE:0:80}\"" \
          "regenerate scenario-model.md via the perf-onboarding skill. The sentinel is its authorship marker." \
          "skills/perf-onboarding/SKILL.md §\"Phase 3 — Scenario-model\""
      fi
      SCENARIOS_DIR="$PROJECT_ROOT/tests/perf/scenarios"
      SCENARIO_FILE_COUNT=$(find "$SCENARIOS_DIR" -maxdepth 1 -name "*.js" -type f 2>/dev/null | wc -l | tr -d ' ')
      if [ "${SCENARIO_FILE_COUNT:-0}" -lt 1 ]; then
        emit_phase_deny "3 (Scenario-model)" \
          "no *.js scenario files found under tests/perf/scenarios/." \
          "create at least one k6 scenario script in tests/perf/scenarios/ before closing Phase 3." \
          "skills/perf-onboarding/SKILL.md §\"Phase 3 — Scenario-model\""
      fi
      ;;
    4)
      # Phase 4 (Baseline): ≥1 baseline JSON file.
      BASELINES_DIR="$PROJECT_ROOT/tests/perf/baselines"
      BASELINE_FILE_COUNT=$(find "$BASELINES_DIR" -maxdepth 1 -name "*.json" -type f 2>/dev/null | wc -l | tr -d ' ')
      if [ "${BASELINE_FILE_COUNT:-0}" -lt 1 ]; then
        emit_phase_deny "4 (Baseline)" \
          "no *.json baseline files found under tests/perf/baselines/." \
          "run the baseline measurement and write at least one baseline JSON file before closing Phase 4." \
          "skills/perf-onboarding/SKILL.md §\"Phase 4 — Baseline\""
      fi
      ;;
    5)
      # Phase 5 (Load-run): ≥1 results JSON file.
      RESULTS_DIR="$PROJECT_ROOT/tests/perf/results"
      RESULTS_FILE_COUNT=$(find "$RESULTS_DIR" -maxdepth 1 -name "*.json" -type f 2>/dev/null | wc -l | tr -d ' ')
      if [ "${RESULTS_FILE_COUNT:-0}" -lt 1 ]; then
        emit_phase_deny "5 (Load-run)" \
          "no *.json result files found under tests/perf/results/." \
          "complete at least one load-run pass and write its results JSON before closing Phase 5." \
          "skills/perf-onboarding/SKILL.md §\"Phase 5 — Load-run\""
      fi
      ;;
    6)
      # Phase 6 (Threshold-gate): threshold-verdict.json + deliberateBreach non-empty.
      VERDICT_PATH="$PROJECT_ROOT/tests/perf/docs/threshold-verdict.json"
      if [ ! -f "$VERDICT_PATH" ]; then
        emit_phase_deny "6 (Threshold-gate)" \
          "tests/perf/docs/threshold-verdict.json does not exist." \
          "run the threshold evaluation and write threshold-verdict.json before closing Phase 6." \
          "skills/perf-onboarding/SKILL.md §\"Phase 6 — Threshold-gate\""
      fi
      DELIBERATE_LEN=$("$JQ" -r 'if (.deliberateBreach | type) == "array" then (.deliberateBreach | length) else -1 end' "$VERDICT_PATH" 2>/dev/null || echo "-1")
      case "$DELIBERATE_LEN" in ''|*[!0-9-]*) DELIBERATE_LEN=-1 ;; esac
      if [ "$DELIBERATE_LEN" -lt 1 ]; then
        emit_phase_deny "6 (Threshold-gate)" \
          "tests/perf/docs/threshold-verdict.json exists but .deliberateBreach is empty or missing. The threshold gate requires explicit deliberate-breach analysis with ≥1 entry." \
          "populate the deliberateBreach field in threshold-verdict.json with at least one threshold deliberation record." \
          "skills/perf-onboarding/SKILL.md §\"Phase 6 — Threshold-gate\""
      fi
      ;;
    7)
      # Phase 7 (Report): perf-report.md + sentinel on line 1.
      REPORT_PATH="$PROJECT_ROOT/tests/perf/docs/perf-report.md"
      if [ ! -f "$REPORT_PATH" ]; then
        emit_phase_deny "7 (Report)" \
          "tests/perf/docs/perf-report.md does not exist." \
          "author the performance report before closing Phase 7." \
          "skills/perf-onboarding/SKILL.md §\"Phase 7 — Report\""
      fi
      REPORT_FIRST_LINE=$(head -n 1 "$REPORT_PATH" 2>/dev/null || echo "")
      if [ "$REPORT_FIRST_LINE" != "<!-- perf-onboarding:report -->" ]; then
        emit_phase_deny "7 (Report)" \
          "tests/perf/docs/perf-report.md is missing the line-1 sentinel \`<!-- perf-onboarding:report -->\`. Got: \"${REPORT_FIRST_LINE:0:80}\"" \
          "regenerate perf-report.md via the perf-onboarding skill. The sentinel is its authorship marker." \
          "skills/perf-onboarding/SKILL.md §\"Phase 7 — Report\""
      fi
      ;;
  esac
}

for phase_id in $PHASES_NEWLY_COMPLETED; do
  perf_check_deliverables "$phase_id"
done

# All checks passed — silent allow.
exit 0
