name: CI

# Vercel variant: Vercel builds Preview deployments on every push and gates the
# PR via the Vercel GitHub integration check. Running `npm run build` here too
# would duplicate Vercel's work for ~3-5 minutes per PR with no added signal.
#
# What this CI covers (and Vercel does not):
#   - Lint (cheap belt-and-suspenders)
#   - Schema-map drift check (docs/SCHEMA_MAP.md updated when scripts/add-*.js changes)
#   - Migrations apply cleanly (node-pg-migrate against ephemeral Postgres 16)
#   - Unit tests (vitest)
#
# NOTE: tcg-vault is JavaScript (not TypeScript). No `npx tsc --noEmit` step.
# Re-enable a type-check job if migrating to TypeScript.

on:
  pull_request:
    branches: [main]
    # Skip CI entirely for doc-only PRs. The `slash-ci-minutes` convoy
    # (2026-06-04) added this filter because every grep / lint / vitest /
    # migrate job that fires on a pure-docs PR (e.g. `.convoys/` seeds +
    # briefs, AGENTS.md edits, `docs/`) costs GitHub Actions minutes for
    # zero signal. The full job set still runs on code-touching PRs.
    paths-ignore:
      - '.convoys/**'
      - '**/*.md'
      - 'docs/**'
      - 'AGENTS.md'
      - '.cursor/**'
      - 'README.md'
  push:
    branches: [main]
    paths-ignore:
      - '.convoys/**'
      - '**/*.md'
      - 'docs/**'
      - 'AGENTS.md'
      - '.cursor/**'
      - 'README.md'

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  NODE_VERSION: '20'

jobs:
  lint:
    name: Lint
    runs-on: [self-hosted, axiom]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
      # Cache node_modules between runs (in addition to setup-node's npm
      # cache). `setup-node@v4`'s `cache: npm` caches `~/.npm` but `npm
      # ci` still does a fresh install into `node_modules` — that's the
      # ~30-45s we want to skip on cache hit. Keyed by package-lock.json
      # hash so any dep change invalidates correctly.
      - name: Cache node_modules
        id: cache-node-modules
        uses: actions/cache@v4
        with:
          path: node_modules
          key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
      - run: npm ci
        if: steps.cache-node-modules.outputs.cache-hit != 'true'
      - run: npm run lint --if-present

  schema-map-fresh:
    name: Schema map up to date
    runs-on: [self-hosted, axiom]
    # Only run when migration scripts or the schema map itself changed.
    # If neither changed, nothing to verify.
    if: |
      contains(github.event.pull_request.changed_files, 'scripts/add-') ||
      contains(github.event.pull_request.changed_files, 'scripts/fix-') ||
      contains(github.event.pull_request.changed_files, 'scripts/setup-neon-db.js') ||
      contains(github.event.pull_request.changed_files, 'migrations/') ||
      contains(github.event.pull_request.changed_files, 'docs/SCHEMA_MAP.md')
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2
      - name: Verify schema map updated alongside migration scripts
        run: |
          MIGRATION_CHANGED=false
          MAP_CHANGED=false
          if git diff --name-only HEAD~1 | grep -qE '^scripts/(add-|fix-|setup-neon-db\.js)'; then
            MIGRATION_CHANGED=true
          fi
          if git diff --name-only HEAD~1 | grep -qE '^migrations/'; then
            MIGRATION_CHANGED=true
          fi
          if git diff --name-only HEAD~1 | grep -q '^docs/SCHEMA_MAP\.md$'; then
            MAP_CHANGED=true
          fi
          if [ "$MIGRATION_CHANGED" = "true" ] && [ "$MAP_CHANGED" = "false" ]; then
            echo "::error::A migration script changed but docs/SCHEMA_MAP.md was not updated."
            echo "Update docs/SCHEMA_MAP.md to reflect the schema change, then re-push."
            exit 1
          fi
          echo "OK: schema map and migration scripts are in sync."

  # Consolidated grep-based regression gates. Previously 6 separate jobs
  # (forbidden-endpoints / -cors-headers / -client-side-llm-keys /
  # -modal-shell-without-primitive / -deprecated-color-aliases /
  # -stale-strings); the `slash-ci-minutes` convoy (2026-06-04) merged
  # them into one job to amortize a single actions/checkout across all
  # 6 checks. Each check still emits `::error file=...::msg` so the
  # in-file annotations work the same way. The job uses a FAIL flag so
  # ALL violations across all 6 checks are reported in a single run
  # (matches the previous independent-jobs behavior; nicer than
  # stop-at-first-failure).
  forbidden-patterns:
    name: Forbidden patterns (7 checks)
    runs-on: [self-hosted, axiom]
    steps:
      - uses: actions/checkout@v4
      - name: Run all forbidden-pattern checks
        run: |
          FAIL=0

          # ---------- (1) No dev endpoints in pages/api ----------
          echo "::group::Check 1/6: No dev endpoints in pages/api"
          BAD_PATHS=(
            "pages/api/simple.js"
            "pages/api/test-auth.js"
            "pages/api/test-db.js"
            "pages/api/setup-database.js"
          )
          FOUND=()
          for path in "${BAD_PATHS[@]}"; do
            if [ -f "$path" ]; then
              FOUND+=("$path")
            fi
          done
          while IFS= read -r path; do
            FOUND+=("$path")
          done < <(find pages/api -maxdepth 4 -type f -name 'test-*.js' 2>/dev/null || true)
          if [ ${#FOUND[@]} -gt 0 ]; then
            echo "::error::Forbidden dev endpoints present in pages/api/. Delete them or move to scripts/."
            for path in "${FOUND[@]}"; do
              echo "::error file=${path}::Forbidden dev endpoint."
            done
            FAIL=1
          else
            echo "OK: no forbidden dev endpoints under pages/api/."
          fi
          echo "::endgroup::"

          # ---------- (2) No wildcard CORS in pages/api ----------
          echo "::group::Check 2/6: No wildcard CORS in pages/api"
          MATCHES=$(grep -rEn 'Access-Control-Allow-(Origin|Methods|Headers)' pages/api/ 2>/dev/null || true)
          if [ -n "$MATCHES" ]; then
            echo "::error::Forbidden CORS headers present under pages/api/. Remove them — same-origin Vercel deployment does not need CORS."
            echo "$MATCHES" | while IFS= read -r line; do
              file=$(echo "$line" | cut -d: -f1)
              lineno=$(echo "$line" | cut -d: -f2)
              echo "::error file=${file},line=${lineno}::Forbidden CORS header — delete this line."
            done
            FAIL=1
          else
            echo "OK: no Access-Control-Allow-* headers under pages/api/."
          fi
          echo "::endgroup::"

          # ---------- (3) No client-side LLM key leakage ----------
          echo "::group::Check 3/6: No client-side LLM key leakage"
          if [ -f pages/api/config/gemini.js ]; then
            echo "::error file=pages/api/config/gemini.js::Forbidden config endpoint — do not return API keys to browsers."
            FAIL=1
          fi
          CONFIG_MATCHES=$(grep -rEn 'apiKey:' pages/api/config/ 2>/dev/null || true)
          if [ -n "$CONFIG_MATCHES" ]; then
            echo "::error::Forbidden apiKey response under pages/api/config/."
            echo "$CONFIG_MATCHES" | while IFS= read -r line; do
              file=$(echo "$line" | cut -d: -f1)
              lineno=$(echo "$line" | cut -d: -f2)
              echo "::error file=${file},line=${lineno}::Do not return API keys from config endpoints."
            done
            FAIL=1
          fi
          AI_OCR_IMPORTS=$(grep -rEn 'from ['\''"].*ai-ocr|import.*ai-ocr' components/ 2>/dev/null || true)
          if [ -n "$AI_OCR_IMPORTS" ]; then
            echo "::error::Browser code must not import lib/ai-ocr — use POST /api/scan/identify instead."
            echo "$AI_OCR_IMPORTS" | while IFS= read -r line; do
              file=$(echo "$line" | cut -d: -f1)
              lineno=$(echo "$line" | cut -d: -f2)
              echo "::error file=${file},line=${lineno}::Remove ai-ocr import; call server-side scan API."
            done
            FAIL=1
          fi
          SERVER_ONLY=(
            lib/scan-vision.js
          )
          LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com|ai-gateway\.vercel\.sh'
          LLM_FOUND=()
          while IFS= read -r file; do
            skip=false
            for so in "${SERVER_ONLY[@]}"; do
              if [ "$file" = "$so" ]; then
                skip=true
                break
              fi
            done
            if [ "$skip" = true ]; then
              continue
            fi
            if grep -qE "$LLM_PATTERN" "$file" 2>/dev/null; then
              LLM_FOUND+=("$file")
            fi
          done < <(find components lib pages -name '*.js' ! -path 'pages/api/*' 2>/dev/null || true)
          if [ ${#LLM_FOUND[@]} -gt 0 ]; then
            echo "::error::Client-side LLM API URLs must not appear outside pages/api/."
            for path in "${LLM_FOUND[@]}"; do
              echo "::error file=${path}::Move LLM calls server-side or add to server-side-scan-pipeline removal list."
            done
            FAIL=1
          fi
          [ ${#LLM_FOUND[@]} -eq 0 ] && [ -z "$CONFIG_MATCHES" ] && [ -z "$AI_OCR_IMPORTS" ] && [ ! -f pages/api/config/gemini.js ] && echo "OK: no client-side LLM key leakage patterns detected."
          echo "::endgroup::"

          # ---------- (4) No legacy modal shells ----------
          echo "::group::Check 4/6: No new modal shells without <Modal> primitive"
          MODAL_FOUND=()
          while IFS= read -r file; do
            MODAL_FOUND+=("$file")
          done < <(grep -lE 'fixed inset-0 bg-black bg-opacity-' \
              pages components -r --include='*.js' 2>/dev/null \
              | sort -u || true)
          if [ ${#MODAL_FOUND[@]} -gt 0 ]; then
            echo "::error::Legacy modal-shell pattern detected. Use the <Modal> primitive from components/ui/."
            for f in "${MODAL_FOUND[@]}"; do
              echo "::error file=${f}::Replace 'fixed inset-0 bg-black bg-opacity-' with <Modal open={…} onClose={…} title=…> from components/ui."
            done
            FAIL=1
          else
            echo "OK: no legacy modal shells anywhere in pages/ or components/."
          fi
          echo "::endgroup::"

          # ---------- (5) No deprecated color aliases ----------
          echo "::group::Check 5/6: No use of deprecated color aliases"
          PATTERNS=(
            'gradient-text-purple'
            'gradient-text-pink'
            'gradient-text-blue'
            'glow-purple'
            'glow-pink'
            'glow-blue'
            'gradient-bg-purple'
            'gradient-bg-blue'
            'gradient-bg-pink'
          )
          ALL_MATCHES=""
          for pattern in "${PATTERNS[@]}"; do
            P_MATCHES=$(grep -rFn "$pattern" pages components --include='*.js' 2>/dev/null || true)
            if [ -n "$P_MATCHES" ]; then
              ALL_MATCHES=$(printf '%s\n%s' "$ALL_MATCHES" "$P_MATCHES")
            fi
          done
          if [ -n "$ALL_MATCHES" ]; then
            echo "::error::Deprecated color alias detected. Use --accent-ember / --accent-flame / --accent-gold (or the .gradient-bg-ember / .gradient-text-ember utility classes)."
            echo "$ALL_MATCHES" | sort -u | while IFS= read -r line; do
              [ -z "$line" ] && continue
              file=$(echo "$line" | cut -d: -f1)
              lineno=$(echo "$line" | cut -d: -f2)
              echo "::error file=${file},line=${lineno}::Deprecated color alias — replace with the canonical Deck Hearth warm-palette equivalent."
            done
            FAIL=1
          else
            echo "OK: no deprecated color aliases in pages/ or components/."
          fi
          echo "::endgroup::"

          # ---------- (6) No stale ownership/collection copy ----------
          echo "::group::Check 6/6: No stale ownership/collection copy"
          STRING_PATTERNS=(
            'Mark Owned'
            'Owned Cards'
            'All My Cards'
          )
          STRING_FOUND=()
          for pattern in "${STRING_PATTERNS[@]}"; do
            while IFS= read -r line; do
              STRING_FOUND+=("$line")
            done < <(grep -rFn "$pattern" pages/ components/ \
              --include='*.js' \
              --exclude-dir=api 2>/dev/null || true)
          done
          if [ ${#STRING_FOUND[@]} -gt 0 ]; then
            echo "::error::Forbidden stale UI string(s) in pages/ or components/. Use lib/collection-vocabulary.js labels — see AGENTS.md § Product vocabulary."
            printf '%s\n' "${STRING_FOUND[@]}" | sort -u | while IFS= read -r line; do
              file=$(echo "$line" | cut -d: -f1)
              lineno=$(echo "$line" | cut -d: -f2)
              text=$(echo "$line" | cut -d: -f3-)
              echo "::error file=${file},line=${lineno}::${text}"
            done
            FAIL=1
          else
            echo "OK: no forbidden stale strings in pages/ or components/."
          fi
          echo "::endgroup::"

          # ---------- (7) No bespoke var(--glass-surface-*) inline styles ----------
          echo "::group::Check 7/7: No bespoke var(--glass-surface-*) inline styles"
          # unify-glass-panel-surfaces convoy, Brief 7 (PR sequence after #126).
          # After Briefs 1-6 land, every panel-shaped surface in the app
          # composes via .glass-panel / .glass-panel-strong /
          # .page-header-glass / <GlassSurface>. Bespoke inline-style use
          # of var(--glass-surface-low|mid|high) under pages/ or
          # components/ JSX is the regression vector this gate prevents.
          #
          # Allowlist (explicit, documented exceptions):
          #   --- Chrome blocks (D4 of architect plan, ratified) ---
          #   - components/ui/GlassSurface.js         # the primitive itself
          #   - components/Layout.js                  # sidebar nav-chip chrome (L850 area)
          #   - components/ui/TopSearchBar.js         # <header> chrome
          #
          # The pending-migration entries (Button / Input /
          # MobileNavigation) were removed by the
          # migrate-button-input-mobilenav-to-glass-primitive convoy
          # (2026-06-05). Adding a fourth chrome-tier entry is a
          # design-system decision — open a new convoy.
          GLASS_ALLOWLIST=(
            "components/ui/GlassSurface.js"
            "components/Layout.js"
            "components/ui/TopSearchBar.js"
          )
          GLASS_FOUND=()
          while IFS= read -r file; do
            skip=false
            for allowed in "${GLASS_ALLOWLIST[@]}"; do
              if [ "$file" = "$allowed" ]; then
                skip=true
                break
              fi
            done
            if [ "$skip" = true ]; then
              continue
            fi
            GLASS_FOUND+=("$file")
          done < <(grep -lE "var\\(--glass-surface-(low|mid|high)\\)" \
              pages components -r --include='*.js' 2>/dev/null \
              | sort -u || true)
          if [ ${#GLASS_FOUND[@]} -gt 0 ]; then
            echo "::error::Bespoke var(--glass-surface-*) inline styles detected outside the documented allowlist."
            echo "Use .glass-panel / .glass-panel-strong / .page-header-glass or compose <GlassSurface> from components/ui/ instead."
            for f in "${GLASS_FOUND[@]}"; do
              echo "::error file=${f}::Replace inline var(--glass-surface-*) with the appropriate class or primitive."
            done
            FAIL=1
          else
            echo "OK: no bespoke glass-surface inline styles outside the allowlist."
          fi
          echo "::endgroup::"

          # ---------- Final exit ----------
          if [ "$FAIL" -ne 0 ]; then
            echo "::error::One or more forbidden-pattern checks failed. See annotations above."
            exit 1
          fi
          echo "All 7 forbidden-pattern checks passed."

  migrate:
    name: Migrations apply (node-pg-migrate)
    runs-on: [self-hosted, axiom]
    # Postgres lives on CT 102 (axiom homelab). Per-run database keeps parallel
    # PRs isolated and a `DROP DATABASE` in `always()` keeps the catalog tidy.
    # `HOMELAB_CI_POSTGRES_PASSWORD` is a repo secret — password only. The
    # `deckhearth_ci` Postgres role has CREATEDB but no superuser; a
    # compromised runner can't reach other apps' databases.
    #
    # psql picks PGHOST/PGUSER/PGPASSWORD/PGPORT up automatically, so we
    # don't build a URL and we sidestep any URL-parsing quirks around
    # special chars in the connection string.
    env:
      PGHOST: 192.168.68.102
      PGPORT: '5432'
      PGUSER: deckhearth_ci
      PGPASSWORD: ${{ secrets.HOMELAB_CI_POSTGRES_PASSWORD }}
      DBNAME: ci_run_${{ github.run_id }}_${{ github.run_attempt }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
      - name: Install postgresql-client
        # myoung34/github-runner image doesn't ship psql. ~10s on first
        # apt-update; the underlying apt cache survives between runs because
        # the ephemeral runner container is recreated but layers are cached
        # by the CT's docker daemon.
        run: sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client
      - name: Cache node_modules
        id: cache-node-modules
        uses: actions/cache@v4
        with:
          path: node_modules
          key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
      - run: npm ci
        if: steps.cache-node-modules.outputs.cache-hit != 'true'
      - name: Create per-run database
        run: |
          psql -d postgres -c "CREATE DATABASE \"$DBNAME\";"
          echo "POSTGRES_URL=postgres://$PGUSER:$PGPASSWORD@$PGHOST:$PGPORT/$DBNAME" >> .env.local
      - run: npm run migrate up
      - name: Drop per-run database
        if: always()
        run: psql -d postgres -c "DROP DATABASE IF EXISTS \"$DBNAME\";"

  test:
    name: Unit tests (vitest)
    runs-on: [self-hosted, axiom]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
      - name: Cache node_modules
        id: cache-node-modules
        uses: actions/cache@v4
        with:
          path: node_modules
          key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
      - run: npm ci
        if: steps.cache-node-modules.outputs.cache-hit != 'true'
      - run: npm run test:run
        env:
          JWT_SECRET: ci-secret-only-for-tests-do-not-use-in-prod
