#!/usr/bin/env bash
#
# endpoint_diff/run.sh -- byte-for-byte comparison of the
# tests/integration/endpoints suite -- retargeted onto the transfer-ownership
# profiles + entity ids -- between TWO git refs, against the local dev server.
#
#   ./run.sh <before-ref> <after-ref> [phase]
#
# It answers: does going from <before-ref> to <after-ref> change any endpoint
# response? <before-ref> is the baseline, <after-ref> is the candidate -- they
# may be any two branches/commits, e.g.
#
#   ./run.sh before_port_with_ordering_fixes fix-nondeterministic-endpoint-ordering
#
# phase (default: all):
#   extract  -- checkout <after-ref>, run the endpoints suite there with the
#               recorder, build manifest.json, retarget it onto the
#               transfer-ownership profiles + ids, then replay it once at
#               <after-ref> (after_extract)
#   before   -- checkout <before-ref>, replay the manifest, capture_before
#   after    -- checkout <after-ref>,  replay the manifest, capture_after_replay
#   compare  -- diff the captures, write _out/report.md
#   all      -- extract -> before -> after -> compare
#
# Set ENDPOINT_DIFF_NO_RETARGET=1 to skip retargeting and diff the recorded
# manifest as-is.
#
# Requires a CLEAN working tree (it runs `git checkout`). The `all` and
# `extract` phases clear _out/ before staging anything, so a fresh comparison
# never inherits a stale artifact; `before` / `after` / `compare` reuse what
# `extract` left there. Each server runs with the reloader off and fakeredis
# (see serve.py), so the two builds never share cache state. Captures are taken
# after / before / after so the two <after-ref> runs bracket the "before" run
# in time and pin down warehouse data drift.
set -u

BEFORE_REF="${1:?usage: run.sh <before-ref> <after-ref> [extract|before|after|compare|all]}"
AFTER_REF="${2:?usage: run.sh <before-ref> <after-ref> [extract|before|after|compare|all]}"
PHASE="${3:-all}"

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../../.." && pwd)"
OUT="$HERE/_out"
PY="$ROOT/.venv/bin/python"
BASE_URL="http://127.0.0.1:5000"
XDIST="${ENDPOINT_DIFF_XDIST:-15}"
CONCURRENCY="${ENDPOINT_DIFF_CONCURRENCY:-15}"
RECORDER_CONFTEST="$ROOT/tests/integration/conftest.py"

# Staged copies of the harness scripts that must outlive a `git checkout`. The
# `before` (and possibly `after`) phase checks out a ref that may predate this
# (tracked) directory, so that checkout deletes serve.py/replay.py/compare.py
# from the working tree. _out/ is git-ignored and survives the checkout, so the
# staged copies are run from there; serve.py honors ENDPOINT_DIFF_ROOT to still
# locate the repo.
SERVE_PY="$OUT/_staged_serve.py"
REPLAY_PY="$OUT/_staged_replay.py"
COMPARE_PY="$OUT/_staged_compare.py"

# Test classes deselected during extraction -- their requests never enter the
# manifest. The /metadata endpoints are excluded from this comparison by
# request. Override with ENDPOINT_DIFF_DESELECT (space-separated pytest ids).
DEFAULT_DESELECT="tests/integration/endpoints/test_product.py::TestProductMetadata \
tests/integration/endpoints/test_sound_recording.py::TestSoundRecordingMetadata"
DESELECT_ARGS=()
for nodeid in ${ENDPOINT_DIFF_DESELECT:-$DEFAULT_DESELECT}; do
  DESELECT_ARGS+=(--deselect "$nodeid")
done

cd "$ROOT"
ORIG_REF="$(git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse HEAD)"
log() { echo "[run.sh $(date +%H:%M:%S)] $*"; }
fail() { log "ERROR: $*"; exit 1; }

require_clean_tree() {
  git diff --quiet && git diff --cached --quiet \
    || fail "working tree not clean -- commit/stash first (run.sh runs git checkout)"
}

checkout() {  # $1 = git ref
  log "checking out $1"
  git checkout --quiet "$1" || fail "git checkout $1 failed"
}

stage_scripts() {
  # Copy the version-independent harness scripts into _out/ so the `before` /
  # `after` phases can still run them after `git checkout` removes this dir.
  cp "$HERE/serve.py"   "$SERVE_PY"   || fail "cannot stage serve.py"
  cp "$HERE/replay.py"  "$REPLAY_PY"  || fail "cannot stage replay.py"
  cp "$HERE/compare.py" "$COMPARE_PY" || fail "cannot stage compare.py"
}

start_server() {  # $1 = label
  log "starting server ($1)"
  ENDPOINT_DIFF_OUT="$OUT" ENDPOINT_DIFF_ROOT="$ROOT" nohup "$PY" "$SERVE_PY" \
    > "$OUT/server_$1.log" 2>&1 &
  echo $! > "$OUT/server.pid"
  for _ in $(seq 1 90); do
    curl -sf "$BASE_URL/hello/" >/dev/null 2>&1 && { log "server ready"; return 0; }
    sleep 1
  done
  cat "$OUT/server_$1.log"
  fail "server ($1) did not become ready"
}

stop_server() {
  [ -f "$OUT/server.pid" ] && kill "$(cat "$OUT/server.pid")" 2>/dev/null
  pkill -f "_staged_serve.py" 2>/dev/null
  rm -f "$OUT/server.pid"
  sleep 2
}

cleanup() {
  stop_server
  local now
  now="$(git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse HEAD)"
  if [ "$now" != "$ORIG_REF" ]; then
    log "restoring working tree to $ORIG_REF (was $now)"
    git checkout --quiet "$ORIG_REF" || log "WARNING: failed to restore $ORIG_REF"
  fi
}
trap cleanup EXIT

phase_extract() {
  log "PHASE extract -- recording the endpoints suite at $AFTER_REF"
  require_clean_tree
  checkout "$AFTER_REF"
  [ -e "$RECORDER_CONFTEST" ] && fail "$RECORDER_CONFTEST already exists -- refusing to clobber"
  cp "$HERE/recorder_conftest.py" "$RECORDER_CONFTEST"
  start_server extract_record
  log "running pytest (xdist -n $XDIST) -- test failures are expected and ignored"
  log "deselected: ${ENDPOINT_DIFF_DESELECT:-$DEFAULT_DESELECT}"
  ENDPOINT_DIFF_OUT="$OUT" "$PY" -m pytest tests/integration/endpoints \
    -n "$XDIST" -p no:cacheprovider -q --no-header "${DESELECT_ARGS[@]}" \
    > "$OUT/extract_pytest.log" 2>&1 || true
  # Surface the extraction result. Failures are expected in general (the
  # suite runs against possibly-broken code), but the count is the clearest
  # signal that an endpoint is broken on <after-ref>. Such endpoints now stay
  # in the manifest -- the recorder records requests whose Session.request
  # raised -- and surface as STATUS diffs, but a spike still warrants a look.
  local extract_summary
  extract_summary="$(grep -aE '[0-9]+ (passed|failed|error)' \
    "$OUT/extract_pytest.log" | tail -n 1)"
  log "extraction pytest result: ${extract_summary:-unknown}"
  case "$extract_summary" in
    *failed*|*error*)
      log "WARNING: tests failed during extraction -- review $OUT/extract_pytest.log" ;;
  esac
  rm -f "$RECORDER_CONFTEST"
  stop_server
  ls "$OUT"/raw_*.jsonl >/dev/null 2>&1 || fail "no raw recordings produced"
  "$PY" "$HERE/build_manifest.py" --out "$OUT" || fail "build_manifest failed"

  if [ -n "${ENDPOINT_DIFF_NO_RETARGET:-}" ]; then
    log "ENDPOINT_DIFF_NO_RETARGET set -- keeping the recorded manifest as-is"
    return
  fi

  # Retarget the recorded manifest onto the transfer-ownership profiles and
  # entity ids; the pre-retarget manifest is kept as manifest_original.json.
  log "PHASE extract -- retargeting manifest onto transfer-ownership inputs"
  cp "$OUT/manifest.json" "$OUT/manifest_original.json"
  "$PY" "$HERE/retarget_manifest.py" \
    --in "$OUT/manifest_original.json" --out "$OUT/manifest.json" \
    || fail "retarget_manifest failed"

  # capture_after_extract.json from build_manifest describes the *original*
  # requests, so it no longer matches the retargeted manifest. Recapture it
  # as a real replay -- t0 of the A-B-A bracket -- against <after-ref>.
  log "PHASE extract -- replaying retargeted manifest at $AFTER_REF (after_extract)"
  local rev; rev="$(git rev-parse HEAD)"
  start_server after_extract
  "$PY" "$REPLAY_PY" --label after_extract --base-url "$BASE_URL" \
    --concurrency "$CONCURRENCY" --git-rev "$rev" --out "$OUT" \
    || fail "after_extract replay failed"
  stop_server
}

phase_before() {
  log "PHASE before -- replaying manifest at $BEFORE_REF"
  [ -f "$OUT/manifest.json" ] || fail "no manifest.json -- run the extract phase first"
  require_clean_tree
  checkout "$BEFORE_REF"
  local rev; rev="$(git rev-parse HEAD)"
  start_server before
  "$PY" "$REPLAY_PY" --label before --base-url "$BASE_URL" \
    --concurrency "$CONCURRENCY" --git-rev "$rev" --out "$OUT"
  stop_server
}

phase_after() {
  log "PHASE after -- replaying manifest at $AFTER_REF"
  [ -f "$OUT/manifest.json" ] || fail "no manifest.json -- run the extract phase first"
  require_clean_tree
  checkout "$AFTER_REF"
  local rev; rev="$(git rev-parse HEAD)"
  start_server after_replay
  "$PY" "$REPLAY_PY" --label after_replay --base-url "$BASE_URL" \
    --concurrency "$CONCURRENCY" --git-rev "$rev" --out "$OUT"
  stop_server
}

phase_compare() {
  log "PHASE compare"
  "$PY" "$COMPARE_PY" --out "$OUT" || log "compare.py exited non-zero (differences found)"
  log "report: $OUT/report.md"
}

# A fresh comparison -- the `all` or `extract` phase -- starts by clearing
# _out/ entirely, so no artifact from a previous run (a stale capture,
# manifest, report or server.pid) can leak into this one. `before` / `after` /
# `compare` must NOT clear: they replay the manifest and reuse the captures the
# `extract` phase left in _out/. Nothing that must outlive a run belongs in
# _out/ -- stage_scripts re-creates the version-independent scripts from their
# tracked sources on every invocation.
if [ "$PHASE" = all ] || [ "$PHASE" = extract ]; then
  log "fresh '$PHASE' run -- clearing $OUT"
  rm -rf "$OUT"
fi
mkdir -p "$OUT"

stage_scripts
case "$PHASE" in
  extract) phase_extract ;;
  before)  phase_before ;;
  after)   phase_after ;;
  compare) phase_compare ;;
  all)
    require_clean_tree
    phase_extract
    phase_before
    phase_after
    phase_compare
    ;;
  *) fail "unknown phase: $PHASE" ;;
esac
log "done."
