#!/usr/bin/env bash
#
# Local CI-parity gate — mirrors .github/workflows/ci.yml.
#
# A green run here means the *deterministic* CI jobs (Format, Clippy,
# Documentation, and the cross-platform compile) will pass. It exists because
# those failures otherwise only surface after a full ~50-min CI matrix:
# e.g. a private intra-doc link (Documentation job) or test-only code that is
# dead on Windows (Test job) — both invisible to `cargo test` / plain clippy.
#
# Change-aware (#850): the Rust gates can only be broken by Rust/Cargo changes,
# so a docs-only push (README, CHANGELOG, *.md, website, …) skips them and the
# pre-push gate finishes in a second. CI still runs every job unconditionally —
# it stays the source of truth — but a docs-only diff cannot turn a Rust gate
# red, so skipping it locally can never produce a local-green / CI-red split.
# `gen_docs --check` additionally guards the committed generated reference, so it
# also runs whenever a file under docs/reference/generated/** changed by hand.
# `full` (release parity) ignores classification and runs everything + tests.
#
# No-test policy (#849): a change to contract code (proxy / tools / config
# schema — anything feeding deterministic output, #498) that carries no test
# signal is flagged. Advisory by default; LEAN_CTX_PREFLIGHT_STRICT_TESTS=1
# makes it blocking.
#
# Usage:
#   scripts/preflight.sh [fast|full]      (default: fast)
#     fast   change-aware: fmt + clippy + doc + gen_docs drift + Windows
#            cross-compile, each skipped when no Rust/generated-doc file changed
#     full   force everything regardless of the diff, plus `cargo test --lib`
#
# Bypass: not from here — use `SKIP_PREFLIGHT=1 git push` / `git push --no-verify`.
#
# CI parity (.github/workflows/ci.yml):
#   - global env: RUSTFLAGS=-Dwarnings, LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD=0
#   - Documentation job: RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps --all-features
#                        cargo run --example gen_docs --features dev-tools -- --check
#   - Clippy job:        cargo clippy --all-features -- -D warnings
#   - Format job:        cargo fmt --check
#   - Test job (Windows) compiles for x86_64-pc-windows-gnu

set -o pipefail

LEVEL="${1:-fast}"
case "$LEVEL" in
  fast|full) ;;
  *) echo "usage: $0 [fast|full]" >&2; exit 2 ;;
esac

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# Base ref for changed-file detection. Overridable for forks/CI that track a
# different upstream; defaults to the branch CI gates against.
BASE_REF="${PREFLIGHT_BASE:-origin/main}"

# ── Change classification (#850/#849) ─────────────────────────────────
# Sets: RUST_CHANGED, GENERATED_DOCS_CHANGED, CONTRACT_CHANGED, TEST_SIGNAL,
# CHANGED_COUNT, CLASSIFY_OK. When the base ref is unreachable (fresh clone,
# detached history) CLASSIFY_OK=0 → callers fall back to running everything.
classify_changes() {
  RUST_CHANGED=0
  GENERATED_DOCS_CHANGED=0
  CONTRACT_CHANGED=0
  CCR_CHANGED=0
  TEST_SIGNAL=0
  CHANGED_COUNT=0
  CLASSIFY_OK=1
  BASE_SHA=""

  local base
  base="$(git -C "$REPO_ROOT" merge-base "$BASE_REF" HEAD 2>/dev/null || true)"
  if [[ -z "$base" ]]; then
    CLASSIFY_OK=0
    return
  fi
  BASE_SHA="$base"

  # base..working-tree (committed + staged + unstaged) ∪ untracked.
  local files
  files="$( {
      git -C "$REPO_ROOT" diff --name-only "$base"
      git -C "$REPO_ROOT" ls-files --others --exclude-standard
    } 2>/dev/null | sed 's#\\#/#g' | sort -u | sed '/^$/d' )"

  CHANGED_COUNT="$(printf '%s\n' "$files" | sed '/^$/d' | grep -c '' || true)"

  local f
  while IFS= read -r f; do
    [[ -z "$f" ]] && continue
    case "$f" in
      rust/*) RUST_CHANGED=1 ;;
    esac
    case "$f" in
      docs/reference/generated/*) GENERATED_DOCS_CHANGED=1 ;;
    esac
    case "$f" in
      rust/src/proxy/*|rust/src/tools/*|rust/src/core/config/schema/*) CONTRACT_CHANGED=1 ;;
    esac
    # CCR recovery surface (#983): a change here must keep the robustness suite
    # green, so flag it for the change-aware `cargo test --lib ccr_robustness` gate.
    case "$f" in
      rust/src/proxy/ccr.rs|rust/src/proxy/ccr_robustness_tests.rs|rust/src/tools/ctx_expand.rs|rust/src/tools/registered/ctx_retrieve.rs|rust/src/core/read_stub_index.rs|rust/src/core/tabular_crush.rs) CCR_CHANGED=1 ;;
    esac
    case "$f" in
      rust/tests/*|*/tests/*|*test*.rs|*tests.rs) TEST_SIGNAL=1 ;;
    esac
  done <<< "$files"

  # Inline tests live in the same .rs file as the code (#[cfg(test)] mod tests),
  # so a filename check misses them. If no test *file* changed, look for test
  # signal inside the Rust diff itself.
  if [[ "$TEST_SIGNAL" -eq 0 ]]; then
    if git -C "$REPO_ROOT" diff "$base" -- rust 2>/dev/null \
        | grep -Eq '^[+-].*(#\[test\]|#\[cfg\(test\)\]|assert(_eq|_ne)?!|proptest!)'; then
      TEST_SIGNAL=1
    fi
  fi
}

cd "$REPO_ROOT/rust"

# Match CI's environment. RUSTFLAGS=-Dwarnings is applied *per step* (only where
# it must change the build fingerprint — the Windows cross-check and the full
# test build) so the fast host checks keep sharing the normal dev target cache
# instead of recompiling the whole dependency tree.
export RUSTDOCFLAGS="-Dwarnings"
export LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD=0
# Keep proptest snappy like CI (local default is 256); override if you want more.
export PROPTEST_CASES="${PROPTEST_CASES:-64}"

# Windows test compiles the GNU target. jemalloc needs MinGW (not available on
# a plain dev box), so we cross-*check* with the default feature set minus
# jemalloc — enough to exercise the same cfg/dead-code analysis that bit us.
WIN_TARGET="x86_64-pc-windows-gnu"
WIN_FEATURES="tree-sitter,embeddings,http-server,team-server,secure-update"

BOLD="\033[1m"; CYAN="\033[1;36m"; GREEN="\033[1;32m"; RED="\033[1;31m"
YELLOW="\033[1;33m"; RESET="\033[0m"

PASSED=()
FAILED=()
SKIPPED=()
WARNED=()

step() { # step "Label" cmd...
  local label="$1"; shift
  printf "\n${CYAN}▶ %s${RESET}\n" "$label"
  printf "${BOLD}  \$ %s${RESET}\n" "$*"
  if "$@"; then
    PASSED+=("$label")
  else
    FAILED+=("$label")
  fi
}

skip() { # skip "Label" "reason"
  printf "\n${YELLOW}⊘ %s — skipped: %s${RESET}\n" "$1" "$2"
  SKIPPED+=("$1: $2")
}

# ── Classify + decide what to run ─────────────────────────────────────
classify_changes

FORCE_ALL=0
[[ "$LEVEL" == "full" ]] && FORCE_ALL=1
[[ "$CLASSIFY_OK" -eq 0 ]] && FORCE_ALL=1

if [[ "$FORCE_ALL" -eq 1 ]]; then
  RUN_RUST=1
  RUN_GEN_DOCS=1
else
  RUN_RUST="$RUST_CHANGED"
  RUN_GEN_DOCS=0
  { [[ "$RUST_CHANGED" -eq 1 ]] || [[ "$GENERATED_DOCS_CHANGED" -eq 1 ]]; } && RUN_GEN_DOCS=1
fi

printf "${BOLD}preflight (%s) — CI-parity gate${RESET}\n" "$LEVEL"
if [[ "$CLASSIFY_OK" -eq 1 ]]; then
  printf "  changed files vs %s: %s  (rust=%s, generated-docs=%s, contract=%s)\n" \
    "$BASE_REF" "$CHANGED_COUNT" "$RUST_CHANGED" "$GENERATED_DOCS_CHANGED" "$CONTRACT_CHANGED"
else
  printf "  ${YELLOW}change classification unavailable (no %s) — running full gate${RESET}\n" "$BASE_REF"
fi

# Always-on cheap gate: whitespace errors + leftover conflict markers. Checks
# the pushed range (base..HEAD) when known, else the working tree.
if [[ -n "$BASE_SHA" ]]; then
  step "Whitespace / conflict markers (git diff --check)" \
    git -C "$REPO_ROOT" diff --check "$BASE_SHA" HEAD
else
  step "Whitespace / conflict markers (git diff --check)" \
    git -C "$REPO_ROOT" diff --check
fi

if [[ "$RUN_RUST" -eq 1 ]]; then
  step "Format (cargo fmt --check)" \
    cargo fmt --check

  step "Clippy (--all-features -D warnings)" \
    cargo clippy --all-features -- -D warnings

  step "Docs (rustdoc -D warnings)" \
    cargo doc --no-deps --all-features
else
  skip "Format / Clippy / Docs" "no Rust/Cargo files changed (docs-only)"
fi

if [[ "$RUN_GEN_DOCS" -eq 1 ]]; then
  step "Generated-docs drift (gen_docs --check)" \
    cargo run --quiet --example gen_docs --features dev-tools -- --check
else
  skip "Generated-docs drift (gen_docs --check)" "no Rust or generated-doc files changed"
fi

if [[ "$RUN_RUST" -eq 1 ]]; then
  if rustup target list --installed 2>/dev/null | grep -qx "$WIN_TARGET"; then
    step "Windows cross-compile ($WIN_TARGET)" \
      env RUSTFLAGS=-Dwarnings cargo check --target "$WIN_TARGET" --lib --tests \
        --no-default-features --features "$WIN_FEATURES"
  else
    skip "Windows cross-compile ($WIN_TARGET)" \
      "target not installed — run: rustup target add $WIN_TARGET"
  fi
else
  skip "Windows cross-compile ($WIN_TARGET)" "no Rust/Cargo files changed (docs-only)"
fi

# CCR robustness gate (#983) — change-aware: `full` already runs every lib test,
# so only `fast` needs this focused build, and only when the recovery surface
# changed. A test build is otherwise kept out of `fast` by design.
if [[ "$LEVEL" != "full" && "$RUN_RUST" -eq 1 && "$CCR_CHANGED" -eq 1 ]]; then
  step "CCR robustness regression (cargo test --lib ccr_robustness)" \
    env RUSTFLAGS=-Dwarnings cargo test --lib --all-features ccr_robustness
elif [[ "$LEVEL" != "full" ]]; then
  skip "CCR robustness regression" "no CCR recovery files changed"
fi

if [[ "$LEVEL" = "full" ]]; then
  step "Unit tests (cargo test --lib)" \
    env RUSTFLAGS=-Dwarnings cargo test --lib --all-features

  # Entrypoint + rules-drift smoke gates (#902/#903). Integration tests, so they
  # build the bin — kept out of `fast` (which deliberately avoids test builds);
  # CI runs them unconditionally via `cargo test --all-features`.
  step "Entrypoint + rules drift (#902/#903)" \
    env RUSTFLAGS=-Dwarnings cargo test --all-features --test entrypoints_wired --test rules_drift
fi

# ── No-test policy (#849) ─────────────────────────────────────────────
# Contract code changed but the diff carries no test signal → flag it. Advisory
# unless LEAN_CTX_PREFLIGHT_STRICT_TESTS=1. Never triggers for docs/metadata-only
# changes (CONTRACT_CHANGED stays 0).
if [[ "$CLASSIFY_OK" -eq 1 && "$CONTRACT_CHANGED" -eq 1 && "$TEST_SIGNAL" -eq 0 ]]; then
  MSG="contract code changed (proxy/tools/config-schema) but the diff adds no test signal — add/adjust tests or justify"
  if [[ "${LEAN_CTX_PREFLIGHT_STRICT_TESTS:-0}" == "1" ]]; then
    printf "\n${RED}▶ No-test policy (#849)${RESET}\n  ${RED}%s${RESET}\n" "$MSG"
    FAILED+=("No-test policy: $MSG")
  else
    printf "\n${YELLOW}▶ No-test policy (#849) — advisory${RESET}\n  ${YELLOW}%s${RESET}\n" "$MSG"
    printf "  ${YELLOW}(set LEAN_CTX_PREFLIGHT_STRICT_TESTS=1 to make this blocking)${RESET}\n"
    WARNED+=("No-test policy: $MSG")
  fi
fi

# ── Summary ───────────────────────────────────────────────────────────
printf "\n${BOLD}── preflight summary ──${RESET}\n"
printf "${GREEN}  ok passed:  %d${RESET}\n" "${#PASSED[@]}"
if [ "${#SKIPPED[@]}" -gt 0 ]; then
  printf "${YELLOW}  -- skipped: %d${RESET}\n" "${#SKIPPED[@]}"
  for s in "${SKIPPED[@]}"; do printf "${YELLOW}      - %s${RESET}\n" "$s"; done
fi
if [ "${#WARNED[@]}" -gt 0 ]; then
  printf "${YELLOW}  !! warned:  %d${RESET}\n" "${#WARNED[@]}"
  for w in "${WARNED[@]}"; do printf "${YELLOW}      - %s${RESET}\n" "$w"; done
fi
if [ "${#FAILED[@]}" -gt 0 ]; then
  printf "${RED}  XX failed:  %d${RESET}\n" "${#FAILED[@]}"
  for f in "${FAILED[@]}"; do printf "${RED}      - %s${RESET}\n" "$f"; done
  printf "\n${RED}preflight FAILED — fix the above before pushing.${RESET}\n"
  exit 1
fi

printf "\n${GREEN}preflight PASSED — safe to push.${RESET}\n"
