#!/usr/bin/env bash
# gstack-diff-scope — categorize what changed in the diff against a base branch
# Usage: source <(gstack-diff-scope main)  → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ...
# Or:    gstack-diff-scope main           → prints SCOPE_*=... lines
#
# Output contract (#2526 — all-false must be distinguishable from "we could
# not look" and from "nothing matched"):
#   exit 0  changed-file set empty            → all false, legitimately nothing
#   exit 0  changed files, >=1 category match → flags
#   exit 2  changed files, ZERO matches       → flags + SCOPE_ERROR=unmatched
#           (+ the unmatched paths as comment lines, so a new top-level layout
#            trips loudly instead of silently disabling reviewers)
#   exit 2  base ref unresolvable             → all false + SCOPE_ERROR=no_base
#           (shallow CI checkout / missing fetch — a green here would mean
#            "we could not look")
# Every line is shell-safe for `source <(...)` consumers: assignments or
# `#`-comments only.
#
# The changed-file set is the UNION of committed diff + working tree +
# untracked files (#2299): /ship detects scope in Step 9, BEFORE it commits in
# Step 15, so uncommitted work must be visible or every scope-gated reviewer
# is skipped on the common start-work-then-ship flow.
set -euo pipefail

# Detect the repo's default branch when no arg is given (#703-class
# platform-agnostic rule): origin/HEAD -> origin/main -> origin/master -> main.
_default_base() {
  git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' && return
  git rev-parse --verify -q origin/main >/dev/null 2>&1 && { echo "main"; return; }
  git rev-parse --verify -q origin/master >/dev/null 2>&1 && { echo "master"; return; }
  echo "main"
}
BASE="${1:-$(_default_base)}"

FRONTEND=false
BACKEND=false
PROMPTS=false
TESTS=false
DOCS=false
CONFIG=false
MIGRATIONS=false
API=false
AUTH=false

_print_flags() {
  echo "SCOPE_FRONTEND=$FRONTEND"
  echo "SCOPE_BACKEND=$BACKEND"
  echo "SCOPE_PROMPTS=$PROMPTS"
  echo "SCOPE_TESTS=$TESTS"
  echo "SCOPE_DOCS=$DOCS"
  echo "SCOPE_CONFIG=$CONFIG"
  echo "SCOPE_MIGRATIONS=$MIGRATIONS"
  echo "SCOPE_API=$API"
  echo "SCOPE_AUTH=$AUTH"
}

# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
# `git diff` return an empty list — all-false with exit 0, a green that means
# "we could not look". Distinguish it before diffing.
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
  _print_flags
  echo "SCOPE_ERROR=no_base"
  echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}"
  exit 2
fi

# Changed files, NUL-delimited (#2526 minor: `git diff --name-only` octal-quotes
# non-ASCII paths, and the trailing quote defeats extension globs; -z avoids it).
FILES_LIST=()
_collect() {
  local f
  while IFS= read -r -d '' f; do
    [ -n "$f" ] && FILES_LIST+=("$f")
  done
}
# Committed diff vs base (merge-base form; two-dot fallback when no merge base).
_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true)
# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo
# with no commits; tolerated.
_collect < <(git diff -z HEAD --name-only 2>/dev/null || true)
# Untracked files: a brand-new component/migration/test is exactly what a
# reviewer should see, and /ship commits it in Step 15 regardless.
_collect < <(git ls-files -z --others --exclude-standard 2>/dev/null || true)

if [ "${#FILES_LIST[@]}" -eq 0 ]; then
  _print_flags
  exit 0
fi

UNMATCHED=()

# Categories are INDEPENDENT booleans (#2299): a single first-match-wins case
# made them mutually exclusive, so Button.test.jsx set FRONTEND but not TESTS
# while util.test.ts set TESTS but not BACKEND — same intent, opposite result,
# purely from arm ordering. Each category now gets its own case; only BACKEND
# stays deliberately exclusive of frontend component/view files.
for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
  m_frontend=false; m_prompts=false; m_tests=false; m_docs=false
  m_config=false; m_migrations=false; m_api=false; m_auth=false; m_backend=false

  # Frontend: CSS, views, components, templates
  case "$f" in
    *.css|*.scss|*.less|*.sass|*.pcss) m_frontend=true ;;
    *.tsx|*.jsx|*.vue|*.svelte|*.astro) m_frontend=true ;;
    *.erb|*.haml|*.slim|*.hbs|*.ejs) m_frontend=true ;;
    *.html) m_frontend=true ;;
    tailwind.config.*|postcss.config.*) m_frontend=true ;;
    app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) m_frontend=true ;;
  esac

  # Prompts: prompt builders, system prompts, generation services
  case "$f" in
    *prompt_builder*|*generation_service*|*writer_service*|*designer_service*) m_prompts=true ;;
    *evaluator*|*scorer*|*classifier_service*|*analyzer*) m_prompts=true ;;
    *voice*.rb|*writing*.rb|*prompt*.rb|*token*.rb) m_prompts=true ;;
    app/services/chat_tools/*|app/services/x_thread_tools/*) m_prompts=true ;;
    config/system_prompts/*) m_prompts=true ;;
  esac

  # Tests
  case "$f" in
    *.test.*|*.spec.*|*_test.*|*_spec.*) m_tests=true ;;
    test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) m_tests=true ;;
  esac

  # Docs
  case "$f" in
    *.md) m_docs=true ;;
  esac

  # Config
  case "$f" in
    package.json|package-lock.json|yarn.lock|bun.lock|bun.lockb) m_config=true ;;
    Gemfile|Gemfile.lock) m_config=true ;;
    *.yml|*.yaml) m_config=true ;;
    .github/*) m_config=true ;;
    requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) m_config=true ;;
  esac

  # Migrations: database migration files. Bare migrations/* covers a
  # root-level migrations dir (#2526); db/data covers the Rails data_migrate
  # gem's DATA migrations (#2455) — arbitrary Ruby run unattended against
  # production data, strictly higher-risk than a schema migration (they also
  # match BACKEND below via their extension, as ordinary app code should).
  case "$f" in
    db/migrate/*|migrations/*|*/migrations/*|alembic/*|prisma/migrations/*) m_migrations=true ;;
    db/data/*|data_migrations/*|*/data_migrations/*) m_migrations=true ;;
  esac

  # API: routes, controllers, endpoints, GraphQL/OpenAPI schemas. Bare api/*
  # covers root-level serverless layouts (Vercel functions, Next.js pages/api
  # at root) that */api/* silently missed (#2526).
  case "$f" in
    api/*|*/api/*|*controller*|*route*|*endpoint*) m_api=true ;;
    *.graphql|*.gql|openapi.*|swagger.*) m_api=true ;;
  esac

  # Auth: authentication, authorization, sessions, permissions
  case "$f" in
    *auth*|*session*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
  esac

  # Backend: code that isn't a frontend component/view file. Includes ESM/CJS
  # (.mjs/.cjs) and explicit-module TS (.mts/.cts) — #1810: these matched no
  # category, so an ESM/CJS-only PR skipped the backend reviewer entirely.
  if [ "$m_frontend" = false ]; then
    case "$f" in
      *.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) m_backend=true ;;
      *.ts|*.js|*.mjs|*.cjs|*.mts|*.cts) m_backend=true ;;
    esac
  fi

  [ "$m_frontend" = true ] && FRONTEND=true
  [ "$m_prompts" = true ] && PROMPTS=true
  [ "$m_tests" = true ] && TESTS=true
  [ "$m_docs" = true ] && DOCS=true
  [ "$m_config" = true ] && CONFIG=true
  [ "$m_migrations" = true ] && MIGRATIONS=true
  [ "$m_api" = true ] && API=true
  [ "$m_auth" = true ] && AUTH=true
  [ "$m_backend" = true ] && BACKEND=true

  if [ "$m_frontend" = false ] && [ "$m_prompts" = false ] && [ "$m_tests" = false ] \
    && [ "$m_docs" = false ] && [ "$m_config" = false ] && [ "$m_migrations" = false ] \
    && [ "$m_api" = false ] && [ "$m_auth" = false ] && [ "$m_backend" = false ]; then
    UNMATCHED+=("$f")
  fi
done

_print_flags

# Changed files but ZERO category matches (#2526): a classifier bug, an
# unrecognised layout, or a new top-level directory would otherwise present
# as "no reviewers needed" with the skip invisible. Trip loudly instead.
if [ "$FRONTEND" = false ] && [ "$BACKEND" = false ] && [ "$PROMPTS" = false ] \
  && [ "$TESTS" = false ] && [ "$DOCS" = false ] && [ "$CONFIG" = false ] \
  && [ "$MIGRATIONS" = false ] && [ "$API" = false ] && [ "$AUTH" = false ]; then
  echo "SCOPE_ERROR=unmatched"
  printf '%s\n' ${UNMATCHED[@]+"${UNMATCHED[@]}"} | sort -u | head -50 | while IFS= read -r u; do
    [ -n "$u" ] && printf '# unmatched: %s\n' "$u"
  done
  exit 2
fi
