#!/bin/bash
# selector-development-inertness-guard.sh — diff-shape validator.
#
# Hook    : PreToolUse:Edit|Write
# Mode    : DENY (when a selector-development scope is in flight AND
#                 the frontend file change is not a single-attribute
#                 additive edit — the inertness contract for the
#                 pipeline)
# Mode    : silent allow (when no .current-scope sentinel exists —
#                 frontend edits outside the selector-development
#                 pipeline have no inertness contract to enforce)
# State   : reads tests/e2e/.selector-development/.current-scope (sentinel)
#           and tests/e2e/.selector-development/.detected-convention if present
# Env     : CONVENTION_OVERRIDE (overrides the cached convention; for tests)
#           WORKSPACE_ROOT (defaults to git toplevel of cwd)
#           CIVITAS_DISABLE_SELECTOR_DEVELOPMENT=1 disables the hook
#           (kill-switch for consumers who never use this workflow)
#
# Why the sentinel gate
# ---------------------
# The inertness contract — "the only allowed edit is appending one
# attribute to one opening tag" — applies during the pipeline-stepper's
# patch step, where the selector-development scope is in flight. Outside
# the pipeline, frontend edits are arbitrary refactor / feature work
# the consumer is doing through Claude Code; gating those would be a
# hostile default for any consumer who hasn't opted into selector-
# development.
#
# So we only enforce the contract when the pipeline says "I'm running
# right now" via the .current-scope sentinel file. Pairs with
# selector-development-pipeline-stepper.sh, which writes/clears the
# sentinel.

set -euo 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

# Resolve hook's own lib directory so the validator can be found when this hook
# is installed into ~/.claude/hooks/ (where $ws/hooks/lib won't exist).
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
HOOK_LIB="$HOOK_DIR/lib"

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')
file_path=$(echo "$input" | "$JQ" -r '.tool_input.file_path // empty')

case "$tool_name" in Edit|Write) ;; *) exit 0 ;; esac

# Kill-switch for consumers who never use selector-development.
if [ "${CIVITAS_DISABLE_SELECTOR_DEVELOPMENT:-0}" = "1" ]; then
  exit 0
fi

# Extension filter — same set as activation-gate
case "$file_path" in
  *.tsx|*.jsx|*.vue|*.svelte|*.html|*.htm) ;;
  *.ts|*.js)
    case "$file_path" in */src/*|*/app/*|*/pages/*|*/components/*) ;; *) exit 0 ;; esac
    ;;
  *) exit 0 ;;
esac

ws="${WORKSPACE_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")}"

# Sentinel gate: only enforce the inertness contract when the
# selector-development pipeline is mid-flight. The pipeline-stepper
# writes .current-scope on scope-init and clears it on commit. Without
# it, this is a normal authoring edit and we have no opinion.
if [ ! -f "$ws/tests/e2e/.selector-development/.current-scope" ]; then
  exit 0
fi

# Fallback for dev-repo edge case where HOOK_DIR/lib doesn't exist but $ws/hooks/lib does
# (e.g. when testing the hook directly from the repo root without postinstall).
[ -d "$HOOK_LIB" ] || HOOK_LIB="$ws/hooks/lib"

# New-file Write: no inertness contract on creation.
if [ ! -f "$file_path" ]; then
  exit 0
fi

before_content=$(cat "$file_path")

# Compute after content from tool payload
if [ "$tool_name" = "Write" ]; then
  after_content=$(echo "$input" | "$JQ" -r '.tool_input.content // empty')
elif [ "$tool_name" = "Edit" ]; then
  old_string=$(echo "$input" | "$JQ" -r '.tool_input.old_string // empty')
  new_string=$(echo "$input" | "$JQ" -r '.tool_input.new_string // empty')
  # Apply the Edit's single-occurrence replacement via python3 (safe for arbitrary content).
  after_content=$(python3 -c "import sys, json
data = json.load(sys.stdin)
print(data['before'].replace(data['old'], data['new'], 1), end='')" <<<"$("$JQ" -n \
    --arg before "$before_content" \
    --arg old "$old_string" \
    --arg new "$new_string" \
    '{before:$before, old:$old, new:$new}')")
fi

# Convention detection
convention="${CONVENTION_OVERRIDE:-}"
if [ -z "$convention" ]; then
  if [ -f "$ws/tests/e2e/.selector-development/.detected-convention" ]; then
    convention=$(cat "$ws/tests/e2e/.selector-development/.detected-convention")
  else
    convention="data-testid"
  fi
fi

# Run validator via temp files to avoid heredoc-with-python quoting issues
before_tmp=$(mktemp)
after_tmp=$(mktemp)
trap 'rm -f "$before_tmp" "$after_tmp"' EXIT

printf '%s' "$before_content" > "$before_tmp"
printf '%s' "$after_content"  > "$after_tmp"

result=$(node -e "
const v = require('$HOOK_LIB/selector-diff-validator.js');
const fs = require('fs');
const r = v.validate({
  before: fs.readFileSync(process.argv[1], 'utf8'),
  after:  fs.readFileSync(process.argv[2], 'utf8'),
  expectedAttr: process.argv[3],
  filePath: process.argv[4]
});
process.stdout.write(JSON.stringify(r));
" "$before_tmp" "$after_tmp" "$convention" "$file_path" 2>/dev/null) \
  || result='{"ok":false,"reason":"node-error","detail":"validator failed to run"}'

ok=$(echo "$result" | "$JQ" -r '.ok')
if [ "$ok" = "true" ]; then
  exit 0
fi

suffix="The only allowed edit is appending exactly one ${convention} attribute (kebab-case value) to one opening tag, with no other byte changes."
echo "$result" | "$JQ" -c \
  --arg sfx "$suffix$(achilles_scope_notice)" \
  '{hookSpecificOutput:{permissionDecision:"deny",permissionDecisionReason:("selector-development-inertness-guard: " + .reason + ". " + (.detail // "") + ". " + $sfx)}}'
exit 0
