#!/usr/bin/env sh

set -eu

SERVER_DIR=server
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)

# ---------------------------------------------------------------------------
# Generated-file registry. `generator_for_artifact` is the lookup: one `case`
# arm per generator, naming the npm script that rebuilds its artifact(s) and
# the source paths whose staging justifies a change without rerunning it.
# Add an arm here -- never a second ad hoc check -- when a new generated pair
# lands under server/src/**/_generated/. A staged file matching no arm is
# refused below instead of silently falling through to whichever generator
# happened to be checked first (the bug this replaces: the old single-row
# check named `generate:contracts` no matter which generated file was staged).
# ---------------------------------------------------------------------------
generator_for_artifact() {
  # $1 = a staged path already known to match server/src/.../_generated/*.ts
  # On match: sets GEN_NPM_SCRIPT / GEN_SOURCE_RE / GEN_ARTIFACT_PATHS and
  # returns 0. On no match: returns 1 and leaves those three untouched.
  #
  # WHY GLOBALS, NOT A "field|field|field" STDOUT ROW: the source regex for
  # a generator with 2+ sources is itself an alternation (`a\.ts|b\.ts`), so
  # a caller-side `cut -d'|'` would split the regex apart wherever ITS OWN
  # `|` lands -- silently truncating it into an unbalanced, unmatchable
  # pattern. sh functions run in the caller's shell (no subshell), so setting
  # variables directly sidesteps the collision instead of picking a "safer"
  # delimiter that a future source path could still contain.
  case "$1" in
    server/src/mcp/contracts/schemas/_generated/*)
      GEN_NPM_SCRIPT='generate:contracts'
      GEN_SOURCE_RE='^server/(scripts/generate-contracts\.ts|tooling/contracts/.*\.json)$'
      GEN_ARTIFACT_PATHS='server/src/mcp/contracts/schemas/_generated/'
      ;;
    server/src/cli-shared/_generated/config-keys.ts|server/src/cli-shared/_generated/config-template.ts)
      GEN_NPM_SCRIPT='generate:config-schema'
      GEN_SOURCE_RE='^server/(scripts/generate-config-schema\.ts|src/shared/types/config-file\.ts)$'
      GEN_ARTIFACT_PATHS='server/config.schema.json server/src/cli-shared/_generated/config-keys.ts server/src/cli-shared/_generated/config-template.ts'
      ;;
    *)
      return 1
      ;;
  esac
}

# Auto-regeneration: if a generator's source changed, regenerate and stage its
# artifacts, whether or not the artifact itself is staged.
regen_generator_if_source_staged() {
  GEN_NPM_SCRIPT=$1
  GEN_SOURCE_RE=$2
  GEN_ARTIFACT_PATHS=$3
  GEN_SOURCE_STAGED=$(printf '%s\n' "$STAGED_FILES" | grep -E "$GEN_SOURCE_RE" || true)
  if [ -n "$GEN_SOURCE_STAGED" ]; then
    echo "📝 $GEN_NPM_SCRIPT source changed - auto-regenerating..."
    npm --prefix "$SERVER_DIR" run -s "$GEN_NPM_SCRIPT"
    # Stage regenerated files
    # shellcheck disable=SC2086 -- GEN_ARTIFACT_PATHS is an intentional word-split list
    git add $GEN_ARTIFACT_PATHS
    echo "✅ $GEN_NPM_SCRIPT artifacts updated and staged"
  fi
}

regen_generator_if_source_staged \
  'generate:contracts' \
  '^server/(scripts/generate-contracts\.ts|tooling/contracts/.*\.json)$' \
  'server/src/mcp/contracts/schemas/_generated/'

regen_generator_if_source_staged \
  'generate:config-schema' \
  '^server/(scripts/generate-config-schema\.ts|src/shared/types/config-file\.ts)$' \
  'server/config.schema.json server/src/cli-shared/_generated/config-keys.ts server/src/cli-shared/_generated/config-template.ts'

# Prevent direct edits to generated files (without a justifying source change).
# Exception 1: deletions are allowed if the source no longer exists (feature removal).
# Exception 2: a staged artifact whose row's source is NOT staged is still
# accepted if regenerating it changes nothing. This is what lets a NEW
# artifact's first commit land when its source predates the generator --
# exactly today's case: config-keys.ts is new, but config-file.ts, the type
# it derives from, already existed and is not itself staged in this commit.
# It passes only when regeneration reproduces the staged bytes, never merely
# because a source file exists somewhere in history.
MODIFIED_GEN_FILES=$(git diff --cached --name-only --diff-filter=M | grep "^server/src/.*_generated/.*\.ts$" || true)
ADDED_GEN_FILES=$(git diff --cached --name-only --diff-filter=A | grep "^server/src/.*_generated/.*\.ts$" || true)
DELETED_GEN_FILES=$(git diff --cached --name-only --diff-filter=D | grep "^server/src/.*_generated/.*\.ts$" || true)
BLOCKABLE_GEN_FILES=$(printf '%s\n%s\n' "$MODIFIED_GEN_FILES" "$ADDED_GEN_FILES" | grep -v '^$' || true)

if [ -n "$BLOCKABLE_GEN_FILES" ]; then
  while IFS= read -r GEN_FILE; do
    [ -z "$GEN_FILE" ] && continue

    if ! generator_for_artifact "$GEN_FILE"; then
      echo ""
      echo "❌ Generated file staged with no registry owner: $GEN_FILE"
      echo ""
      echo "   generator_for_artifact() in .husky/pre-commit must name the generator"
      echo "   that owns this path before it can be committed."
      echo ""
      exit 1
    fi

    GEN_SOURCE_STAGED=$(printf '%s\n' "$STAGED_FILES" | grep -E "$GEN_SOURCE_RE" || true)
    if [ -n "$GEN_SOURCE_STAGED" ]; then
      continue
    fi

    npm --prefix "$SERVER_DIR" run -s "$GEN_NPM_SCRIPT" >/dev/null
    # shellcheck disable=SC2086 -- GEN_ARTIFACT_PATHS is an intentional word-split list
    if git diff --quiet -- $GEN_ARTIFACT_PATHS; then
      continue
    fi

    echo ""
    echo "❌ Direct edits to a generated file detected: $GEN_FILE"
    echo ""
    echo "   Edit its source instead, then run: npm run $GEN_NPM_SCRIPT"
    echo ""
    exit 1
  done <<EOF
$BLOCKABLE_GEN_FILES
EOF
fi

# Allow deletions - these are valid when removing features/contracts
if [ -n "$DELETED_GEN_FILES" ]; then
  echo "📝 Generated file deletions detected (feature removal):"
  echo "   $DELETED_GEN_FILES"
  echo "   ✅ Allowed - ensure source contracts were also removed"
fi

# Run server-scoped lint-staged for files inside server/
SERVER_STAGED=$(printf '%s\n' "$STAGED_FILES" | grep '^server/' || true)
if [ -n "$SERVER_STAGED" ]; then
  (cd "$SERVER_DIR" && npm run -s lint:staged)
fi

# Check formatting of repo-level JSON/Markdown/YAML files with server's Prettier.
#
# CHECK, not write (changed 2026-08-25). This used to `prettier --write` the staged paths and
# then `git add` them, which meant the bytes committed were not the bytes anything had
# validated: every gate in this hook ran against one version of the file and the commit
# captured another. Formatting-only, so the blast radius was small -- but it is the same shape
# as the defects the security review kept finding, where a step silently changes the artifact
# after the check that blessed it.
#
# It also broke editing tools. A rewrite between an agent's read and its next anchored edit
# makes fixed-string replacements miss silently, which cost real work in this repo on
# 2026-08-25 (plan implementation notes reported as written while the file was unchanged).
#
# `--check` is additionally a strict subset of CI, which runs `validate:format` (also
# `--check`). The previous `--write` was a step CI does not run -- exactly what CLAUDE.md
# § Validation Gates forbids.
ROOT_FORMATTABLE=$(printf '%s\n' "$STAGED_FILES" | grep -v '^server/' | grep -E '\.(json|md|ya?ml)$' || true)
if [ -n "$ROOT_FORMATTABLE" ]; then
  echo "🔍 Checking formatting of staged repo-level text files..."
  if ! printf '%s\n' "$ROOT_FORMATTABLE" | xargs npx --prefix "$SERVER_DIR" prettier --check; then
    echo ""
    echo "❌ Staged files are not Prettier-formatted."
    echo ""
    echo "   Fix them:  npm --prefix $SERVER_DIR run format"
    echo "   Then re-stage:  git add -f -- <the files above>"
    echo ""
    echo "   -f because some tracked paths (.claude/rules/) are gitignored, and a plain"
    echo "   re-add refuses those."
    exit 1
  fi
fi

# Project guidance compatibility projection. Claude-owned files are canonical; AGENTS.md is the
# generated filename Codex and OpenCode prefer. Render from the INDEX so a partially staged source
# file cannot leak unstaged guidance into the commit. This runs after formatting so both source and
# projection contain the same committed bytes.
GUIDANCE_CHANGES=$(printf '%s\n' "$STAGED_FILES" | grep -E '^(CLAUDE\.md|AGENTS\.md|\.claude/rules/.*\.md|scripts/sync-project-guidance\.js)$' || true)
if [ -n "$GUIDANCE_CHANGES" ]; then
  echo "📝 Regenerating project AGENTS.md from canonical Claude guidance..."
  node scripts/sync-project-guidance.js --write --from-index
  git add -f -- AGENTS.md
  echo "✅ Project guidance projection updated and staged"
fi

# Restore local Python lint/format parity when hooks change
HOOK_CHANGES=$(printf '%s\n' "$STAGED_FILES" | grep '^hooks/' || true)
if [ -n "$HOOK_CHANGES" ]; then
  echo "🐍 Validating Python hooks..."
  npm --prefix "$SERVER_DIR" run -s validate:python
fi

# Route by CHANGED PATHS, using the same classifier pre-push and CI use.
#
# `scripts/classify-validation-scope.js` is the declared changed-path SSOT (CLAUDE.md § Validation
# Gates). pre-push and ci.yml both consult it; this hook did not, so a markdown-only commit paid a
# whole-`src` lint and a whole-project typecheck that examined zero changed lines. The classifier
# is deliberately conservative: only documented handbooks/docs/plans/READMEs reach `docs`, only
# `hooks/**` (plus docs) reaches `hooks`, and every empty, mixed, executable, config, dependency or
# unrecognized change falls through to `full`.
#
# Scope by path CLASS, never by diff size: a 3-line concurrency fix is decision-bearing while a
# 40-line generated update is mechanical, so a line-count threshold would gate the wrong thing.
VALIDATION_SCOPE=$(printf '%s\n' "$STAGED_FILES" | node scripts/classify-validation-scope.js --scope)

if [ "$VALIDATION_SCOPE" = "docs" ] || [ "$VALIDATION_SCOPE" = "hooks" ]; then
  echo "✅ Pre-commit: $VALIDATION_SCOPE-only change — skipping typecheck (pre-push still runs it)."
  exit 0
fi

# Typecheck. `ci-release.md` § Required Hooks lists this for pre-commit under a <10s
# budget; measured at 6.1s wall clock on this repo, so the budget holds.
#
# `lint:ratchet` deliberately does NOT run here. It ran in this hook AND in pre-push, at 38s a
# call, and it is a whole-project DIRECTION measure -- CLAUDE.md: "a project-wide ratchet measures
# direction, not conformance". Direction is a push concern; per-commit conformance is `lint:staged`
# above, which lints exactly what is staged. Running it per-commit also meant any unrelated
# violation anywhere in `src` blocked every commit, which in a shared worktree blocks on work that
# is not yours. Coverage is unchanged: pre-push runs it, CI runs it, and every local route stays a
# strict subset of CI.
npm --prefix "$SERVER_DIR" run -s typecheck
