#!/usr/bin/env bash
#
# NuOS catalogue pre-commit hook (WU 111 enforcement phase).
#
# Catches drift before commit. After WU 111's Phase J ship, the
# accepted-decision rule has flipped from warning → block (per the
# pack's narrower-than-originally-planned enforcement scope, recorded
# in WU 111's "Forward-compatibility commitments" section). Other
# rules described in WU 128's original aggressive list are NOT shipping
# — distinguishing tool-written from human-written content was deemed
# overengineering for a planning-artefact catalogue.
#
# Active rules:
#   1. index-drift detection — every WU/decision/open-question/risk file
#      must have a matching row in its _index.md (and vice versa)
#   2. active-decision modification block — modifying a committed
#      `accepted` decision file is BLOCKED (not just warned). The
#      discipline is to write a superseding D-NNN+1 and link forward.
#      To deliberately fix a typo or link in an accepted decision,
#      use `git commit --no-verify` (CLAUDE.md prohibits this for
#      substantive changes; reserve it for typo-only fixes).
#
# Sentinel-protected sections (e.g. STATE.md's `nuos:sentinel`) remain
# protected via the existing `.claude/hooks/check-catalogue-write.sh`
# Claude Code hook. That rule continues unchanged at WU 111 ship.
#
# Bypass: this hook respects --no-verify like any other. The CLAUDE.md
# policy explicitly prohibits --no-verify use for substantive changes;
# the technical block fires at the CI server-side check (a future WU).

set -uo pipefail

REPO_ROOT="$(git rev-parse --show-toplevel)"
ENFORCEMENT_LOG="$REPO_ROOT/.nuos-enforcement.log"
EXIT_CODE=0

red()    { printf '\033[31m%s\033[0m\n' "$*"; }
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
green()  { printf '\033[32m%s\033[0m\n' "$*"; }
dim()    { printf '\033[2m%s\033[0m\n' "$*"; }

log_event() {
  printf '%s | %s | %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" "$2" >> "$ENFORCEMENT_LOG"
}

# ---------- Generic index-drift checker ---------------------------------
#
# Args:
#   $1 — kind label (for messages)
#   $2 — directory (relative to repo root)
#   $3 — _index.md path (relative to repo root)
#   $4 — filename regex (matches IDs the directory contains)
#   $5 — index-row ID regex (a Perl-compatible regex that anchors on the
#        leftmost column of an index table row)
#
# The two regexes must extract the SAME set of IDs (e.g. "001", "030g",
# "D042") so set comparison works.

check_index_drift() {
  local kind="$1" dir="$2" index="$3" file_regex="$4" row_regex="$5"

  if [[ ! -d "$REPO_ROOT/$dir" ]]; then return 0; fi
  if [[ ! -f "$REPO_ROOT/$index" ]]; then return 0; fi

  # IDs extracted from filenames in the directory (top-level + one subdir
  # like done/, resolved/, superseded/)
  local ids_in_tree
  ids_in_tree=$(cd "$REPO_ROOT/$dir" && {
    find . -maxdepth 2 -type f -name "*.md" \
      -not -name "_index.md" \
      -not -name "*-template.md" 2>/dev/null \
      | sed -nE "$file_regex" \
      | sort -u
  })

  # IDs extracted from leftmost column of table rows in the index
  local ids_in_index
  ids_in_index=$(sed -nE "$row_regex" "$REPO_ROOT/$index" | sort -u)

  local missing_from_index
  missing_from_index=$(comm -23 <(printf '%s\n' "$ids_in_tree") <(printf '%s\n' "$ids_in_index"))

  if [[ -n "$missing_from_index" ]]; then
    red "✖ index-drift ($kind): on disk but missing from $index:"
    while IFS= read -r id; do echo "    — $id"; done <<< "$missing_from_index"
    log_event "index-drift" "$kind missing from index: $(echo "$missing_from_index" | tr '\n' ',')"
    EXIT_CODE=1
  fi
}

dim "[nuos:pre-commit] index-drift check"

# Note: BSD sed (macOS default) is fussy about `|` as both delimiter and
# regex content. Using `#` as the s/// delimiter sidesteps the collision.

# Work units: filenames are NNN-slug.md or NNNa-slug.md (with optional letter).
# Index rows are `| NNN |` or `| NNNa |` in the leftmost column.
check_index_drift \
  "work-units" \
  "docs/build/work-units" \
  "docs/build/work-units/_index.md" \
  's#^\./(done/)?([0-9]{3}[a-z]?)-[^/]*\.md$#\2#p' \
  's#^\| ([0-9]{3}[a-z]?) \|.*$#\1#p'

# Decisions: filenames are DNNN-slug.md.
# Index rows: `| DNNN |` or `| [DNNN](...) |`.
check_index_drift \
  "decisions" \
  "docs/build/decisions" \
  "docs/build/decisions/_index.md" \
  's#^\./(done/|superseded/)?(D[0-9]{3})-[^/]*\.md$#\2#p' \
  's#^\| \[?(D[0-9]{3}).*$#\1#p'

# Open questions: filenames are QNNN-slug.md.
check_index_drift \
  "open-questions" \
  "docs/build/open-questions" \
  "docs/build/open-questions/_index.md" \
  's#^\./(resolved/)?(Q[0-9]{3})-[^/]*\.md$#\2#p' \
  's#^\| \[?(Q[0-9]{3}).*$#\1#p'

# Risks: per current convention, individual risk files are inline in
# risks/_index.md (no per-risk .md files yet). Skip the check entirely
# until that pattern changes.
if compgen -G "$REPO_ROOT/docs/build/risks/R[0-9][0-9][0-9]-*.md" > /dev/null; then
  check_index_drift \
    "risks" \
    "docs/build/risks" \
    "docs/build/risks/_index.md" \
    's#^\./(R[0-9]{3})-[^/]*\.md$#\1#p' \
    's#^\| \[?(R[0-9]{3}).*$#\1#p'
fi

# ---------- Rule 2: active-decision modification block (WU 111 ship) ---

dim "[nuos:pre-commit] active-decision modification check"
#
# "Immutable once accepted" — this blocks edits only to a decision whose
# status *in HEAD* is already a locked state (`accepted` or `active`).
# Editing a still-`proposed` decision is allowed: promoting it to
# accepted/active is the sanctioned lifecycle step, not a violation, and
# proposed decisions are in-flight by design. New decision files are
# additions (excluded by --diff-filter=M), so a decision born `accepted`
# is never blocked on creation. The locked-status check uses the HEAD
# pre-image, so flipping an accepted decision back to `proposed` to sneak
# a substantive edit is still caught.
candidate_decisions=$(git diff --cached --name-only --diff-filter=M \
  | grep -E '^docs/build/decisions/D[0-9]+.*\.md$' \
  | grep -v '/superseded/' \
  || true)

locked_decisions=""
if [[ -n "$candidate_decisions" ]]; then
  while IFS= read -r f; do
    [[ -z "$f" ]] && continue
    head_status=$(git show "HEAD:$f" 2>/dev/null \
      | grep -m1 -E '^\*\*Status:\*\*' \
      | sed -E 's/^\*\*Status:\*\*[[:space:]]*//' \
      | awk '{print tolower($1)}')
    case "$head_status" in
      accepted|active) locked_decisions+="${f}"$'\n' ;;
    esac
  done <<< "$candidate_decisions"
fi

if [[ -n "$locked_decisions" ]]; then
  red "✖ active-decision modification — BLOCKED (WU 111 enforcement):"
  while IFS= read -r f; do [[ -n "$f" ]] && echo "    — $f"; done <<< "$locked_decisions"
  red "  Decisions are immutable once accepted. The discipline is to write a"
  red "  superseding D-NNN+1 and link forward. Use:"
  red "    nuos-catalogue decision supersede <target> --by=<new-D> --reason=\"...\""
  red ""
  red "  If this edit is a non-substantive typo fix or link cleanup that does"
  red "  not change the decision's meaning, you may bypass this block with"
  red "  --no-verify. CLAUDE.md prohibits --no-verify for substantive changes."
  log_event "active-decision-block" "$(echo "$locked_decisions" | tr '\n' ',')"
  EXIT_CODE=1
fi

# ---------- Rule 3: STATE.md generated-region drift block (WU 113b Stage B) ---

# Only run when docs/build/STATE.md is in the staged changes.
# Guard on nuos-catalogue being present and supporting `state drift-check`.
# Fail-open: if the binary is absent, old (doesn't know drift-check), or
# errors for any infra reason, skip this check silently — a missing binary
# must never block all commits.
#
# Old-binary detection: an old binary (< 0.35.0) exits non-zero with
# "unknown state subcommand: drift-check" on stderr. We distinguish this
# from a genuine drift finding by checking whether the output contains the
# drift-specific marker phrase. If the output does NOT contain "generated regions"
# (the phrase only the new drift-check command emits), we skip.
staged_state_md=$(git diff --cached --name-only | grep -F 'docs/build/STATE.md' || true)

if [[ -n "$staged_state_md" ]]; then
  dim "[nuos:pre-commit] STATE.md generated-region drift check (WU 113b)"

  if ! command -v nuos-catalogue > /dev/null 2>&1; then
    dim "[nuos:pre-commit] nuos-catalogue not found — skipping STATE.md drift check"
  else
    # Run drift-check; capture output + exit code.
    drift_output=$(nuos-catalogue state drift-check 2>&1) || drift_exit=$?
    drift_exit=${drift_exit:-0}

    if [[ $drift_exit -ne 0 ]]; then
      # Non-zero exit — check whether this is a genuine drift finding or an
      # infra/version problem (old binary, missing store, etc.).
      if echo "$drift_output" | grep -qF 'generated regions'; then
        # Confirmed generated-region drift — block the commit.
        red "✖ STATE.md generated-region drift — BLOCKED (WU 113b enforcement):"
        echo "$drift_output" | while IFS= read -r line; do echo "  $line"; done
        log_event "state-drift-block" "generated-region drift detected"
        EXIT_CODE=1
      else
        # Not a drift finding (unknown subcommand from old binary, infra error, etc.)
        # — skip silently (fail open).
        dim "[nuos:pre-commit] STATE.md drift check returned non-zero (not a drift finding) — skipping"
      fi
    fi
  fi
fi

# ---------- Result ------------------------------------------------------

if [[ $EXIT_CODE -eq 0 ]]; then
  green "[nuos:pre-commit] all rules pass (WU 111 enforcement)"
  log_event "pre-commit-pass" "$(git diff --cached --name-only | wc -l | tr -d ' ') files"
fi
exit $EXIT_CODE
