#!/bin/bash
# PreToolUse hook: blocks manifest gate-passed updates when required
# skill/agent was not invoked. Reads gate-requirements.json for mapping
# and telemetry.jsonl for invocation history.
#
# Only fires on Edit/Write targeting manifest.yaml with gate-passed: true.
# Returns { continue: false } to block, { continue: true } to allow.
#
# Gate matching handles three YAML cases:
#   1. Inline:  requirements: { status: complete, gate-passed: true }
#   2. Block:   requirements:\n  gate-passed: true  (name on different line)
#   3. Narrow edit: old_string="gate-passed: false" new_string="gate-passed: true"

set -euo pipefail

INPUT=$(cat)

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
FILE_PATH=""
CONTENT=""

# Extract file path and content based on tool type
if [ "$TOOL_NAME" = "Edit" ]; then
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
  CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // ""')
elif [ "$TOOL_NAME" = "Write" ]; then
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
  CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""')
elif [ "$TOOL_NAME" = "MultiEdit" ]; then
  # MultiEdit targets one file with multiple edits. file_path is at the top
  # level (same as Edit); the new content lives in tool_input.edits[*].new_string.
  # Concatenate all new_string fields so gate detection scans every proposed
  # change in the batch — otherwise a multi-edit could slip a `gate-passed: true`
  # write past the enforcement gate that single-Edit catches.
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
  CONTENT=$(echo "$INPUT" | jq -r '[.tool_input.edits[]?.new_string] | join("\n")')
else
  echo '{"continue":true}'
  exit 0
fi

# Only check manifest.yaml edits
if [[ "$FILE_PATH" != *manifest.yaml ]]; then
  echo '{"continue":true}'
  exit 0
fi

# Inline YAML on a gate key is a bypass surface — `code-review-final: { status:
# passed, gate-passed: true }` sneaks past the block-form line-anchored regex
# below because `gate-passed:` is mid-line. Reject inline form on any gate key
# explicitly so authors must use block form (where enforcement actually works).
# A gate key followed immediately by `{` indicates flow-mapping syntax; pair
# that with `gate-passed: true` anywhere on the same line and we treat it as a
# hard block.
INLINE_BYPASS_LINE=$(echo "$CONTENT" | grep -nE "^[[:space:]]*[a-z][a-z0-9-]*:[[:space:]]*\{[^}]*gate-passed:[[:space:]]*true" || true)
if [ -n "$INLINE_BYPASS_LINE" ]; then
  echo "GATE ENFORCEMENT: inline-form YAML for a gate-passed assignment is not allowed (line: ${INLINE_BYPASS_LINE}). Use block form so the enforcer can validate prerequisites:" >&2
  echo "  code-review-final:" >&2
  echo "    status: complete" >&2
  echo "    gate-passed: true" >&2
  echo "{\"continue\":false,\"reason\":\"GATE ENFORCEMENT: inline-form YAML on a gate-passed assignment is not allowed; use block form.\"}"
  exit 2
fi

# Only check edits that set gate-passed: true.
# Anchor to actual YAML-key syntax: line must start (after optional whitespace) with
# the literal key "gate-passed:" followed by optional whitespace and "true". This
# prevents false-positives on prose/comments that mention the phrase — e.g. a
# manifest comment explaining when the field becomes true.
if ! echo "$CONTENT" | grep -qE "^[[:space:]]*gate-passed:[[:space:]]*true([[:space:]]|$|,|})"; then
  echo '{"continue":true}'
  exit 0
fi

# Locate config and telemetry
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG="$SCRIPT_DIR/../config/gate-requirements.json"
PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
TELEMETRY="$PROJECT_ROOT/.forge/state/telemetry.jsonl"

if [ ! -f "$CONFIG" ]; then
  echo '{"continue":true}' # No config = no enforcement
  exit 0
fi

# Extract work_id from the manifest path: .forge/work/{type}/{name}/manifest.yaml
# Used to filter telemetry by work item so a stale invocation from another work
# item cannot satisfy this gate.
WORK_ID=""
if [[ "$FILE_PATH" =~ \.forge/work/([^/]+)/([^/]+)/manifest\.yaml$ ]]; then
  WORK_ID="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
fi

# Check each gate being passed against requirements
BLOCKED_REASONS=""

for GATE in $(jq -r 'keys[] | select(. != "_comment")' "$CONFIG"); do
  GATE_MATCH=false

  # Method 1: Gate name appears anywhere in the edit content
  # Handles inline YAML and block YAML (gate name on different line from gate-passed)
  if echo "$CONTENT" | grep -q "$GATE"; then
    GATE_MATCH=true
  # Method 2 (Edit/MultiEdit): Gate name is near the edit location in the actual file
  # Handles narrow edits (e.g., changing just "gate-passed: false" to "true")
  elif [ "$TOOL_NAME" = "Edit" ] && [ -f "$FILE_PATH" ]; then
    OLD_STRING=$(echo "$INPUT" | jq -r '.tool_input.old_string // ""')
    if [ -n "$OLD_STRING" ]; then
      FIRST_LINE=$(echo "$OLD_STRING" | head -1)
      if [ -n "$FIRST_LINE" ] && grep -B 10 -F "$FIRST_LINE" "$FILE_PATH" 2>/dev/null | grep -q "[[:space:]]*${GATE}[[:space:]]*:"; then
        GATE_MATCH=true
      fi
    fi
  elif [ "$TOOL_NAME" = "MultiEdit" ] && [ -f "$FILE_PATH" ]; then
    # For each edit's old_string, check the first line and see whether the
    # gate name appears in nearby file context. Emit one first-line per edit
    # (jq splits on records, not embedded newlines) so multi-line old_strings
    # still get probed by their starting line.
    EDIT_COUNT=$(echo "$INPUT" | jq '.tool_input.edits | length // 0')
    for ((i = 0; i < EDIT_COUNT; i++)); do
      OLD_STRING=$(echo "$INPUT" | jq -r ".tool_input.edits[$i].old_string // \"\"")
      [ -z "$OLD_STRING" ] && continue
      FIRST_LINE=$(echo "$OLD_STRING" | head -1)
      if [ -n "$FIRST_LINE" ] && grep -B 10 -F "$FIRST_LINE" "$FILE_PATH" 2>/dev/null | grep -q "[[:space:]]*${GATE}[[:space:]]*:"; then
        GATE_MATCH=true
        break
      fi
    done
  fi

  if [ "$GATE_MATCH" = true ]; then
    REQUIRED_SKILL=$(jq -r ".\"$GATE\".skill // empty" "$CONFIG")

    # Resolve required agents: agents array first, then single agent field
    REQUIRED_AGENTS=$(jq -r "if .\"$GATE\".agents then .\"$GATE\".agents[] else .\"$GATE\".agent // empty end" "$CONFIG" 2>/dev/null)

    # Telemetry check is work_id-scoped (closes P0-5 cross-work-item bypass).
    # Records without work_id (legacy or no-active-work) are NOT counted —
    # an untagged invocation can't satisfy a gate on a specific work item.
    # Records lacking jq-parsable shape are also ignored (jq returns null per line;
    # `select(...)` filters them out).
    check_invocation() {
      local NAME="$1"
      if [ ! -f "$TELEMETRY" ]; then
        return 1
      fi
      if [ -z "$WORK_ID" ]; then
        # No work_id resolved from path — should be rare; fall back to bare-name
        # check so we don't accidentally hard-block manifest edits made outside
        # the .forge/work/{type}/{name}/ tree (unlikely given the path filter
        # at line ~43 already excluded non-manifest paths, but defensive).
        grep -q "\"name\":\"$NAME\"" "$TELEMETRY" 2>/dev/null
      else
        # jq -s collects all JSONL lines into an array. select() narrows to the
        # exact work_id + name pair. length > 0 means at least one invocation
        # in this work item. The 2>/dev/null swallows parse errors on
        # malformed records (treated as no match — correct behavior).
        local COUNT
        COUNT=$(jq -s --arg work_id "$WORK_ID" --arg name "$NAME" \
          'map(select(.work_id == $work_id and .name == $name)) | length' \
          "$TELEMETRY" 2>/dev/null || echo 0)
        [ "$COUNT" != "0" ] && [ -n "$COUNT" ]
      fi
    }

    # Check skill invocation in telemetry (work_id-scoped)
    if [ -n "$REQUIRED_SKILL" ]; then
      if ! check_invocation "$REQUIRED_SKILL"; then
        if [ -n "$WORK_ID" ]; then
          BLOCKED_REASONS="${BLOCKED_REASONS}Gate '$GATE' requires skill '$REQUIRED_SKILL' (not invoked for work_id '$WORK_ID'). "
        else
          BLOCKED_REASONS="${BLOCKED_REASONS}Gate '$GATE' requires skill '$REQUIRED_SKILL' (not found in telemetry). "
        fi
      fi
    fi

    # Check agent dispatch in telemetry (work_id-scoped)
    for AGENT in $REQUIRED_AGENTS; do
      if [ -n "$AGENT" ] && [ "$AGENT" != "null" ]; then
        if ! check_invocation "$AGENT"; then
          if [ -n "$WORK_ID" ]; then
            BLOCKED_REASONS="${BLOCKED_REASONS}Gate '$GATE' requires agent '$AGENT' (not dispatched for work_id '$WORK_ID'). "
          else
            BLOCKED_REASONS="${BLOCKED_REASONS}Gate '$GATE' requires agent '$AGENT' (not found in telemetry). "
          fi
        fi
      fi
    done
  fi
done

if [ -n "$BLOCKED_REASONS" ]; then
  # The hook's JSON `reason` field is currently swallowed by Claude Code (only
  # the generic "Execution stopped by hook" line is surfaced to the user).
  # Echo the reason to stderr so it actually reaches the user — stderr from
  # PreToolUse hooks IS surfaced in the tool-error output.
  echo "GATE ENFORCEMENT: ${BLOCKED_REASONS}Load the required skill/agent before marking the gate as passed." >&2

  # Escape for JSON (still emit reason in case a future harness surfaces it).
  ESCAPED=$(echo "$BLOCKED_REASONS" | sed 's/"/\\"/g')
  echo "{\"continue\":false,\"reason\":\"GATE ENFORCEMENT: ${ESCAPED}Load the required skill/agent before marking the gate as passed.\"}"
  # PreToolUse hooks only block when exit code is 2; exit 0 would allow the
  # tool call despite the JSON block decision. Keep the JSON emit above for
  # user-facing context.
  exit 2
fi

echo '{"continue":true}'
exit 0
