#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Git PRE-COMMIT hook for this repo.
#
# A "pre-commit hook" is just a script git runs automatically every time you
# type `git commit`, BEFORE the commit is recorded. If this script exits with
# an error, git cancels the commit. So it's a safety net: it runs the STAR
# YAML test suite and stops you from committing if something is broken.
#
# It is wired up via:   git config core.hooksPath STARVote_LH_tabulation_engine/tools_adam/scripts/git-hooks
# (so the hook lives in the repo and is version-controlled, unlike .git/hooks).
# ---------------------------------------------------------------------------
set -u

ROOT="$(git rev-parse --show-toplevel)"
ENGINE="$ROOT/STARVote_LH_tabulation_engine"

# Prefer the project's virtualenv python; fall back to system python3.
PY="python3"
[ -x "$ROOT/.venv/bin/python" ] && PY="$ROOT/.venv/bin/python"

# --------------------------------------------------------------------------
# Staging generated output — ONLY what this run actually produced.
#
# This checkout is routinely open in two sessions sharing one index and HEAD, and
# `git add <folder>` adopts everything dirty in that folder, including the other
# session's in-flight work. That is not a cosmetic problem: for a pathspec commit
# (`git commit -- <paths>`) git builds a TEMPORARY index and points GIT_INDEX_FILE
# at it, so whatever this hook stages lands in the commit even though the author
# explicitly scoped it. Verified 2026-08-06 in a throwaway repo: a one-file
# `git commit -- mine.txt` committed two files because the hook staged the other.
# On 2026-08-05 that turned a 3-file commit into 18, and later carried another
# session's file deletions into an unrelated commit.
#
# The fix is NOT to skip staging on a scoped commit — these files must ride along
# with the sources they describe, and test_yaml_index_current / test_catalog_current
# / test_divergence_index_current fail the build if they don't. Instead: hash the
# output paths before the generator runs and again after, and stage only what
# changed in between. Anything already dirty that this run did not touch is
# somebody else's edit and is deliberately left alone.
# --------------------------------------------------------------------------

# path<TAB>sha for every file under the given generated paths. Missing paths just
# contribute nothing, which is what makes create/delete fall out of the diff.
snapshot_paths() {
  find "$@" -type f -print0 2>/dev/null \
    | xargs -0 shasum 2>/dev/null \
    | awk '{ sha=$1; $1=""; sub(/^ +/,""); print $0 "\t" sha }' \
    | sort
}

# stage_regenerated <snapshot-before> <path>…  — stage only paths whose content
# this run created, changed or removed. WARNs loudly if a declared output path is
# missing: a bare `git add … || true` hides the one failure that matters, when a
# generator's output moves in a reorg and the index silently drifts stale.
stage_regenerated() {
  before="$1"; shift
  for f in "$@"; do
    if [ ! -e "$f" ]; then
      echo "pre-commit: WARNING — generated path missing, nothing staged: ${f#"$ROOT"/}"
      echo "            The generator's output path probably moved — update this hook."
      return
    fi
  done
  after="$(snapshot_paths "$@")"
  # Symmetric difference = created, rewritten or deleted by THIS run. `git add -A`
  # so a path the generator pruned is staged as a deletion rather than left behind.
  # NOTE the leading-tab strip BEFORE cut: `comm -3` puts lines unique to the
  # SECOND list in column 2, i.e. prefixed with a TAB — so cutting field 1 first
  # yields an empty string for every newly created or rewritten file, which is
  # exactly the set that matters. Get this backwards and the hook silently stages
  # only deletions.
  changed="$(comm -3 <(printf '%s\n' "$before") <(printf '%s\n' "$after") \
             | sed 's/^\t//' | cut -f1 | sort -u | grep -v '^$' || true)"
  [ -z "$changed" ] && return
  printf '%s\n' "$changed" | while IFS= read -r p; do
    [ -n "$p" ] || continue
    git add -A -- "$p" 2>/dev/null \
      || echo "pre-commit: WARNING — could not stage ${p#"$ROOT"/}"
  done
}

# Repo hygiene: WARN (never block) about misplaced/junk pastes — generic img_N.png,
# raw "Ballot Data*.json" drops, etc. — so a real file in the wrong place/name gets
# noticed instead of silently ignored by .gitignore. Does not abort the commit.
if [ -f "$ROOT/STARVote_LH_tabulation_engine/tools_adam/scripts/check_repo_hygiene.py" ]; then
  "$PY" "$ROOT/STARVote_LH_tabulation_engine/tools_adam/scripts/check_repo_hygiene.py" || true
fi

# Refresh the cross-method divergence ledger (generated; never blocks the commit).
# Re-tabulates the curated single-winner STAR library under RCV-IRV / RCV-RR /
# Approval and rewrites method_comparisons/divergence_review/ (INDEX.md, divergence.csv, and the
# per-case teaching cases/*.md), then stages what it rewrote — not the whole
# folder — so the review surface matches the committed elections without
# adopting a concurrent session's unrelated edits in the same directory.
if [ -f "$ROOT/STARVote_LH_tabulation_engine/tools_adam/scripts/build_divergence_index.py" ]; then
  _snap="$(snapshot_paths "$ROOT/method_comparisons/divergence_review")"
  if "$PY" "$ROOT/STARVote_LH_tabulation_engine/tools_adam/scripts/build_divergence_index.py" >/dev/null 2>&1; then
    stage_regenerated "$_snap" "$ROOT/method_comparisons/divergence_review"
  else
    echo "pre-commit: divergence ledger refresh failed — skipping (commit not blocked)."
  fi
fi

# Refresh the multi-race (contested elections) index from the frozen exports and
# stage it, so multirace_elections.md always matches the committed cases. Never
# blocks the commit.
if [ -f "$ROOT/STARVote_LH_tabulation_engine/tools_adam/scripts/build_multirace_index.py" ]; then
  _snap="$(snapshot_paths "$ROOT/07_Concepts/YAML_test_case_index/multirace_elections.md")"
  if "$PY" "$ROOT/STARVote_LH_tabulation_engine/tools_adam/scripts/build_multirace_index.py" >/dev/null 2>&1; then
    stage_regenerated "$_snap" "$ROOT/07_Concepts/YAML_test_case_index/multirace_elections.md"
  else
    echo "pre-commit: multirace index refresh failed — skipping (commit not blocked)."
  fi
fi

# Refresh the by-voting-method index (07_Concepts/YAML_test_case_index/README.md)
# and stage it. This one is guarded in CI by test_yaml_index_current.py but is NOT
# in the hook's pytest subset below, so before this block existed the *only* thing
# that noticed a stale index was a red build on master — which is exactly how it
# went stale for six commits (2026-08-05). Its three sibling indexes in the same
# folder were already refreshed here; this closes the gap. Never blocks.
if [ -f "$ROOT/STARVote_LH_tabulation_engine/tools_adam/scripts/build_yaml_index.py" ]; then
  _snap="$(snapshot_paths "$ROOT/07_Concepts/YAML_test_case_index/README.md")"
  if "$PY" "$ROOT/STARVote_LH_tabulation_engine/tools_adam/scripts/build_yaml_index.py" >/dev/null 2>&1; then
    stage_regenerated "$_snap" "$ROOT/07_Concepts/YAML_test_case_index/README.md"
  else
    echo "pre-commit: YAML index refresh failed — skipping (commit not blocked)."
  fi
fi

# Refresh the faceted catalog (CATALOG.md + races.csv + elections.csv) and stage
# it. The single slice-and-dice view over every election & race. Never blocks.
if [ -f "$ROOT/STARVote_LH_tabulation_engine/tools_adam/scripts/build_catalog.py" ]; then
  _snap="$(snapshot_paths "$ROOT/07_Concepts/YAML_test_case_index/CATALOG.md" \
                          "$ROOT/07_Concepts/YAML_test_case_index/races.csv" \
                          "$ROOT/07_Concepts/YAML_test_case_index/elections.csv")"
  if "$PY" "$ROOT/STARVote_LH_tabulation_engine/tools_adam/scripts/build_catalog.py" >/dev/null 2>&1; then
    stage_regenerated "$_snap" "$ROOT/07_Concepts/YAML_test_case_index/CATALOG.md" \
                    "$ROOT/07_Concepts/YAML_test_case_index/races.csv" \
                    "$ROOT/07_Concepts/YAML_test_case_index/elections.csv"
  else
    echo "pre-commit: catalog refresh failed — skipping (commit not blocked)."
  fi
fi

# If pytest isn't installed, don't block the commit — just say how to get it.
if ! "$PY" -c "import pytest" >/dev/null 2>&1; then
  echo "pre-commit: pytest not installed for $PY — skipping tests."
  echo "            install it with:  $PY -m pip install pytest"
  exit 0
fi

echo "pre-commit: running STAR YAML test suite…"
# The last three are the backstops for the three refresh blocks above: the regen
# makes each generated file correct, these prove it did. They're complements, not
# duplicates — every refresh block here is deliberately non-blocking, so a
# generator that dies (or a `git add` the hook couldn't do) leaves a stale file
# and says so only in a line that scrolls past. Without them the drift ships and
# CI catches it on master, which is exactly how the YAML index sat stale for six
# commits (2026-08-05) and how a divergence page for an uncommitted case shipped
# and broke the docs build. ~14s together, nearly all of it the divergence
# ledger re-tabulating the library — the only honest way to know it's right.
( cd "$ENGINE" && "$PY" -m pytest \
    tests/test_single_winner_positive.py \
    tests/test_negative_validation.py \
    tests/test_harness_selfcheck.py \
    tests/test_json_to_yaml_conversion.py \
    tests/test_lot_number_tiebreak.py \
    tests/test_approval_mirror.py \
    tests/test_composed_mirror.py \
    tests/test_readme_index_complete.py \
    tests/test_yaml_index_current.py \
    tests/test_catalog_current.py \
    tests/test_divergence_index_current.py -q )
status=$?

if [ "$status" -ne 0 ]; then
  echo
  echo "  ✗ Tests failed — commit aborted."
  echo "    Fix the issue (or run the tests above to see what broke), then commit again."
  echo "    To bypass this check once (not recommended):  git commit --no-verify"
  exit 1
fi

echo "  ✓ Tests passed."
exit 0
