name: CI and Deploy

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup .NET (resilient)
        shell: bash
        # actions/setup-dotnet on this single self-hosted runner intermittently
        # leaves a partial extraction in the shared tool-cache ("tar: Cannot open:
        # File exists") or corrupts the SDK download. Install into a clean private
        # dir via dotnet-install.sh and retry once on failure, mirroring the
        # npm ci / NuGet retries elsewhere in this workflow.
        run: |
          export DOTNET_ROOT="$HOME/.dotnet"
          export PATH="$DOTNET_ROOT:$PATH"

          validate_sdk() {
            [ -x "$DOTNET_ROOT/dotnet" ] \
              && "$DOTNET_ROOT/dotnet" --list-sdks | grep -q '^9\.' \
              && "$DOTNET_ROOT/dotnet" --info >/dev/null
          }

          if validate_sdk; then
            echo "Reusing valid runner-local .NET 9 SDK."
          else
            install() {
              local feed="${1:-}"
              local -a feed_args=()
              if [ -n "$feed" ]; then
                feed_args=(--azure-feed "$feed")
              fi
              curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
              rm -rf "$DOTNET_ROOT"
              bash /tmp/dotnet-install.sh --channel 9.0 --install-dir "$DOTNET_ROOT" "${feed_args[@]}"
              validate_sdk
            }
            install || ( echo "dotnet install failed ($?) — retrying from alternate Microsoft CDN..." \
              && install https://dotnetcli.azureedge.net/dotnet )
          fi
          echo "$DOTNET_ROOT" >> "$GITHUB_PATH"
          "$DOTNET_ROOT/dotnet" --info

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: job-tracker-ui/package-lock.json

      - name: Test repository safety scripts
        # Standard-library only and plan-only: this validates the synthetic benchmark harness
        # without contacting Ollama, pulling a model, or requiring package installation.
        run: python3 scripts/test-ollama-evaluation.py

      - name: Build backend
        run: dotnet build JobTrackerApi/JobTrackerApi.csproj --configuration Release

      # The "Build backend" step above only builds JobTrackerApi, so the test project needs its own
      # restore + build. These are separate steps on purpose: this runner is flaky (see the SDK and
      # npm retries in this file) and the job log is not readable from the API, so distinct steps make
      # the failing phase identifiable from step boundaries alone. Restore retries once, mirroring the
      # npm ci pattern below. The suite itself is never weakened or filtered.
      - name: Restore backend tests
        run: |
          restore() { dotnet restore JobTrackerApi.Tests/JobTrackerApi.Tests.csproj; }
          restore || ( echo "NuGet restore failed ($?) — retrying once..." && restore )

      - name: Build backend tests
        run: dotnet build JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore

      # Restore and build both pass on this runner, but the test run dies after ~3s -- far too fast to
      # have executed 306 tests. The suite passes on Windows, in a clean Linux container, under a 1GB
      # memory cap, and with the SDK installed to a custom dir without DOTNET_ROOT, so the cause is
      # specific to this runner. This one-test smoke separates "the test host cannot start at all"
      # from "something in the suite takes the host down"; the log is not readable via the API, so the
      # step boundary is the signal.
      - name: Audit backend dependencies
        run: dotnet list JobTrackerApi/JobTrackerApi.csproj package --vulnerable --include-transitive

      - name: Test backend (host smoke)
        run: dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~CvBuilderTests.Every_catalog_theme_renders_valid_html"

      # This runner fails at a DIFFERENT stage on different runs with no readable diagnostics
      # (test host at 3s; on another run `dotnet restore` at 0s). See
      # docs/infrastructure/runner-investigation.md. Collection parallelism stays off for
      # determinism, the same reason the frontend runs --runInBand. Every test runs; nothing skipped.
      - name: Test backend
        run: dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-build -- xUnit.parallelizeTestCollections=false xUnit.maxParallelThreads=1

      - name: Install frontend deps
        working-directory: job-tracker-ui
        env:
          npm_config_audit: 'false'
          npm_config_fund: 'false'
        run: |
          node -v
          npm -v
          # npm ci occasionally segfaults on the runner (SIGSEGV/139, a memory/native
          # flake). Retry once with a clean node_modules before failing the job.
          npm ci --no-audit --no-fund \
            || ( echo "npm ci failed ($?) — cleaning node_modules and retrying once..." \
                 && rm -rf node_modules \
                 && npm ci --no-audit --no-fund )

      # Moderate React Router advisories remain documented and mitigated; high/critical findings
      # in either production or test/browser tooling block the build.
      - name: Audit frontend dependencies
        working-directory: job-tracker-ui
        run: npm audit --audit-level=high

      - name: Test frontend
        working-directory: job-tracker-ui
        # Run the WHOLE suite. Never whitelist test files here again: the previous
        # whitelist silently skipped new suites and let two regressions reach main.
        run: npm test -- --watchAll=false --runInBand

      - name: Install browser smoke runtime
        working-directory: job-tracker-ui
        run: npx playwright install --with-deps chromium

      - name: Test browser smoke flows
        working-directory: job-tracker-ui
        run: npm run test:e2e

      - name: Build frontend
        working-directory: job-tracker-ui
        env:
          CI: 'false'
          GENERATE_SOURCEMAP: 'false'
          NODE_OPTIONS: --max-old-space-size=4096
        # CRA's build (Terser minify + fork-ts-checker workers) has repeatedly died silently on
        # this runner with no error output (OOM/SIGSEGV signature — same resource-starved-runner
        # class as the npm ci and dotnet-install flakes elsewhere in this workflow). Retry once.
        run: |
          npm run build \
            || ( echo "Frontend build failed ($?) — retrying once..." && npm run build )

  deploy:
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Run remote deploy
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          command_timeout: 40m
          script: |
            set -euo pipefail
            if [ ! -d /opt/job-tracker/app/.git ]; then
              echo "Expected git checkout at /opt/job-tracker/app but .git was not found."
              exit 1
            fi
            cd /opt/job-tracker/app
            if ! git fetch --all --prune; then
              echo "git fetch failed on server. Check remote auth/URL for /opt/job-tracker/app."
              exit 1
            fi
            if ! git rev-parse --verify --quiet ${{ github.sha }} >/dev/null; then
              echo "Commit ${{ github.sha }} is not available in the server checkout after fetch."
              exit 1
            fi
            git reset --hard ${{ github.sha }}
            git clean -fd
            chmod +x deploy/deploy.sh
            DEPLOY_BUILD_AI_SERVICE=true \
            APP_VERSION=${{ github.run_number }} \
            APP_COMMIT_SHA=${{ github.sha }} \
            APP_BUILD_STAMP="$(date -u +'%Y-%m-%d %H:%M UTC')" \
            ./deploy/deploy.sh
            docker compose -f docker-compose.yml ps
            AI_CONTAINER_ID="$(docker compose -f docker-compose.yml ps -q ai-service)"
            if [ -z "$AI_CONTAINER_ID" ]; then
              echo "AI service container id could not be resolved after deploy. Continuing because AI is not a deploy gate for the core app."
            else
              ATTEMPTS=90
              SLEEP_SECS=2
              i=1
              while [ "$i" -le "$ATTEMPTS" ]; do
                HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$AI_CONTAINER_ID" 2>/dev/null || echo unknown)"
                if [ "$HEALTH_STATUS" = "healthy" ]; then
                  break
                fi
                if [ "$HEALTH_STATUS" = "unhealthy" ]; then
                  echo "AI service became unhealthy during deploy readiness wait. Continuing because AI is not a deploy gate for the core app."
                  docker compose -f docker-compose.yml logs --tail=200 ai-service || true
                  break
                fi
                sleep "$SLEEP_SECS"
                i=$((i + 1))
              done
              if [ "${HEALTH_STATUS:-unknown}" != "healthy" ]; then
                echo "AI service did not become healthy within $((ATTEMPTS * SLEEP_SECS)) seconds. Final status: ${HEALTH_STATUS:-unknown}. Continuing because AI is not a deploy gate for the core app."
                docker compose -f docker-compose.yml ps
                docker compose -f docker-compose.yml logs --tail=200 ai-service || true
              fi
            fi
