name: CI

on:
  pull_request:
    branches: [main]

jobs:
  changes:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    permissions:
      pull-requests: read
    outputs:
      code: ${{ steps.filter.outputs.code }}
    steps:
      - uses: dorny/paths-filter@v4
        id: filter
        with:
          filters: |
            code:
              - '**/*.ts'
              - '**/*.tsx'
              - '**/*.js'
              - '**/*.json'
              - '**/*.yaml'
              - '**/*.yml'
              - 'apps/**'
              - 'packages/**'
              - 'pnpm-lock.yaml'
              - 'Dockerfile'
              - 'docker-compose*.yml'

  fast-checks:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Enable pnpm
        run: corepack enable pnpm

      # actions/cache@v4 is intentionally omitted — probe (D-PROBE-04) showed it
      # times out on this runner (socket hang-up between runner container and job
      # container cache server). pnpm install without cache takes ~30s; acceptable.

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Lint
        run: pnpm lint

      - name: Format check
        run: pnpm format:check

      - name: Markdown lint
        run: pnpm md:lint

      - name: Typecheck
        run: pnpm typecheck

      - name: PWA unit tests
        run: pnpm --filter @familysync/pwa test

  api:
    runs-on: ubuntu-latest
    needs: [changes]
    if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
    # Runs in PARALLEL with fast-checks (D-03) — skipped for doc-only PRs.
    services:
      mariadb:
        image: mariadb:11
        env:
          MARIADB_ROOT_PASSWORD: root
          MARIADB_DATABASE: familysync
          MARIADB_USER: familysync
          MARIADB_PASSWORD: testpass
        options: >-
          --health-cmd="healthcheck.sh --connect --innodb_initialized"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=10
          --health-start-period=30s
    # Throwaway creds scoped to the ephemeral service container — never production secrets (T-08-03).
    env:
      DB_HOST: mariadb
      DB_PORT: 3306
      DB_USER: familysync
      DB_PASSWORD: testpass
      DB_NAME: familysync
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Enable pnpm
        run: corepack enable pnpm

      # actions/cache@v4 intentionally omitted — same reasoning as fast-checks job (D-PROBE-04).

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # Pitfall 11: service container healthy != MariaDB accepting connections.
      # No mysql CLI in the runner image (D-PROBE-03); poll via the already-installed
      # mysql2 driver using an inline Node script. 90s deadline covers cold-start InnoDB init.
      - name: Wait for MariaDB to accept connections
        # No mysql CLI in the runner image (D-PROBE-03). Poll via the mysql2 driver
        # already installed in apps/pwa (devDependency). --input-type=commonjs forces
        # CJS mode even though apps/pwa has "type":"module" in its package.json.
        run: |
          node --input-type=commonjs - <<'EOF'
          const mysql = require('mysql2/promise');
          const deadline = Date.now() + 90_000;
          (async () => {
            while (true) {
              try {
                const conn = await mysql.createConnection({
                  host: process.env.DB_HOST,
                  port: Number(process.env.DB_PORT ?? 3306),
                  user: process.env.DB_USER,
                  password: process.env.DB_PASSWORD,
                  database: process.env.DB_NAME,
                });
                await conn.query('SELECT 1');
                await conn.end();
                console.log('MariaDB ready');
                process.exit(0);
              } catch (err) {
                if (Date.now() >= deadline) {
                  console.error('MariaDB did not become ready within 90s:', err.message);
                  process.exit(1);
                }
                await new Promise(r => setTimeout(r, 3000));
              }
            }
          })();
          EOF
        working-directory: apps/pwa

      # Apply schema migrations. Uses drizzle-kit migrate (applies committed SQL files).
      # Never use drizzle push — unsafe on MariaDB (emits destructive TRUNCATE diff, T-08-04).
      - name: Run DB migrations
        run: pnpm --filter @familysync/api db:migrate

      # Full DB-backed API test suite (all tests in apps/api/tests/ require a real MariaDB).
      - name: Run API tests
        run: pnpm --filter @familysync/api test

  harness:
    runs-on: ubuntu-latest
    needs: [changes]
    if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
    # Runs in PARALLEL with fast-checks (D-03) — skipped for doc-only PRs.
    services:
      mariadb:
        image: mariadb:11
        env:
          MARIADB_ROOT_PASSWORD: root
          MARIADB_DATABASE: familysync
          MARIADB_USER: familysync
          MARIADB_PASSWORD: testpass
        options: >-
          --health-cmd="healthcheck.sh --connect --innodb_initialized"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=10
          --health-start-period=30s
    # Throwaway creds scoped to the ephemeral service container — never production secrets (T-08-06).
    env:
      DB_HOST: mariadb
      DB_PORT: 3306
      DB_USER: familysync
      DB_PASSWORD: testpass
      DB_NAME: familysync
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Enable pnpm
        run: corepack enable pnpm

      # actions/cache@v4 intentionally omitted — same reasoning as fast-checks job (D-PROBE-04).

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # Pitfall 11: service container healthy != MariaDB accepting connections.
      # No mysql CLI in the runner image (D-PROBE-03); poll via the mysql2 driver
      # already installed in apps/pwa (devDependency). --input-type=commonjs forces
      # CJS mode even though apps/pwa has "type":"module" in its package.json.
      - name: Wait for MariaDB to accept connections
        run: |
          node --input-type=commonjs - <<'EOF'
          const mysql = require('mysql2/promise');
          const deadline = Date.now() + 90_000;
          (async () => {
            while (true) {
              try {
                const conn = await mysql.createConnection({
                  host: process.env.DB_HOST,
                  port: Number(process.env.DB_PORT ?? 3306),
                  user: process.env.DB_USER,
                  password: process.env.DB_PASSWORD,
                  database: process.env.DB_NAME,
                });
                await conn.query('SELECT 1');
                await conn.end();
                console.log('MariaDB ready');
                process.exit(0);
              } catch (err) {
                if (Date.now() >= deadline) {
                  console.error('MariaDB did not become ready within 90s:', err.message);
                  process.exit(1);
                }
                await new Promise(r => setTimeout(r, 3000));
              }
            }
          })();
          EOF
        working-directory: apps/pwa

      # Apply schema migrations. Uses drizzle-kit migrate (applies committed SQL files).
      # Never use drizzle push — unsafe on MariaDB (emits destructive TRUNCATE diff, T-08-07).
      - name: Run DB migrations
        run: pnpm --filter @familysync/api db:migrate

      # Seed the dev user (id=1). DEV_AUTH_BYPASS injects DEV_USER (id=1) into the request
      # context in-memory only — it never writes a users row (devBypass.ts). global-setup.ts
      # seeds calendars/lists/events for user_id=1 but ASSUMES that user row already exists
      # (true on the dev DB, false on a fresh CI DB): without it the calendars INSERT IGNORE is
      # silently skipped on the users FK, so calendar 10 is missing and the calendar_events
      # insert fails its FK. Idempotent INSERT IGNORE; matches DEV_USER (oidc dev/dev-user, #4A90D9).
      - name: Seed dev user (id=1)
        run: |
          node --input-type=commonjs - <<'EOF'
          const mysql = require('mysql2/promise');
          (async () => {
            const conn = await mysql.createConnection({
              host: process.env.DB_HOST,
              port: Number(process.env.DB_PORT ?? 3306),
              user: process.env.DB_USER,
              password: process.env.DB_PASSWORD,
              database: process.env.DB_NAME,
            });
            await conn.execute(
              "INSERT IGNORE INTO users (id, oidc_iss, oidc_sub, display_name, color) VALUES (1, 'dev', 'dev-user', 'Dev User', '#4A90D9')",
            );
            console.log('seeded dev user id=1');
            await conn.end();
          })();
          EOF
        working-directory: apps/pwa

      # Build the API before starting it — dist/ is gitignored and does not exist in CI (Pitfall 4).
      - name: Build API
        run: pnpm --filter @familysync/api build

      # Install Playwright browsers with system deps BEFORE starting the API, so the long
      # browser download does not run during the API's lifetime.
      # Must run from apps/pwa/ where @playwright/test is installed (D-PROBE-05 confirmed exit 0).
      # Do NOT cache browser binaries — Playwright explicitly recommends against it in CI.
      - name: Install Playwright browsers
        run: npx playwright install --with-deps webkit chromium
        working-directory: apps/pwa

      # Start the API AND run the harness in ONE step. A bare `node &` started in an EARLIER
      # step is reaped at the step boundary: CI run #7 proved :3000 was healthy during a
      # separate "wait" step but dead by the time global-setup polled :5173/health → :3000
      # (after the multi-minute browser install). Keeping the API a child of THIS step's shell
      # guarantees it stays alive for the entire Playwright run.
      # DEV_AUTH_BYPASS=true + NODE_ENV=development are set both inline and in env: — global-setup.ts
      # refuses NODE_ENV=production and the API devBypass.ts checks development. DB_* come from env:.
      # CI=true makes Playwright start Vite :5173 itself (reuseExistingServer=false), use
      # retries:2/workers:1, and apply reporter:'github' — which --reporter=list,html overrides
      # because Gitea does not render github annotations (Pitfall 5 / D-06). Both projects run.
      - name: Run harness (start API + Playwright iphone + pixel + desktop)
        env:
          CI: 'true'
          # Use 127.0.0.1 (not localhost): the runner image resolves `localhost` to ::1 first,
          # but the Vite dev server binds IPv4-only (127.0.0.1:5173). global-setup.ts uses Node
          # fetch (no IPv4 fallback, unlike curl), so localhost→::1:5173 → ECONNREFUSED → its
          # /health poll never returns 200. Proven via [::1]:5173 ECONNREFUSED vs 127.0.0.1:5173 200.
          # --dns-result-order=ipv4first is defense-in-depth for any remaining localhost hop
          # (Vite's /health proxy → localhost:3000; the API is dual-stack so that hop already works).
          PLAYWRIGHT_BASE_URL: http://127.0.0.1:5173
          NODE_OPTIONS: '--dns-result-order=ipv4first'
          DEV_AUTH_BYPASS: 'true'
          NODE_ENV: development
          DB_HOST: mariadb
          DB_PORT: 3306
          DB_USER: familysync
          DB_PASSWORD: testpass
          DB_NAME: familysync
        run: |
          NODE_ENV=development DEV_AUTH_BYPASS=true node apps/api/dist/index.js &
          API_PID=$!
          echo "API PID: $API_PID"

          # Wait for the API :3000/health before launching Playwright (D-02 / T-08-08).
          deadline=$((SECONDS + 60))
          until curl -sf http://localhost:3000/health > /dev/null 2>&1; do
            if ! kill -0 "$API_PID" 2>/dev/null; then echo "API process exited before becoming ready"; exit 1; fi
            if [ $SECONDS -ge $deadline ]; then echo "API did not become ready within 60s"; kill "$API_PID" 2>/dev/null || true; exit 1; fi
            sleep 2
          done
          echo "API ready at :3000"

          # Run the Phase 7/14 harness across all three profiles (iphone, pixel, desktop); preserve its exit code, always kill the API.
          # Call the pwa test:e2e script DIRECTLY (single pnpm layer) and append --reporter without a
          # `--` separator: `pnpm <root> test:e2e -- <args>` double-forwards the `--` into
          # `playwright test -- <args>`, where playwright treats --reporter as a test-file filter →
          # "No tests found" (run #10). The filtered single-layer form forwards the flag cleanly.
          set +e
          pnpm --filter @familysync/pwa test:e2e --reporter=list,html
          rc=$?
          kill "$API_PID" 2>/dev/null || true
          exit $rc

      # Upload traces/screenshots/videos on failure for debugging (D-06).
      # MUST use ChristopherHX/gitea-upload-artifact@v4 — the standard upload-artifact action
      # detects Gitea as GHES and aborts (Pitfall 6 / D-PROBE-06).
      - name: Upload Playwright test artifacts
        if: failure()
        uses: https://github.com/ChristopherHX/gitea-upload-artifact@v4
        with:
          name: playwright-traces-${{ github.run_id }}
          # Upload BOTH the raw traces/screenshots/videos (test-results/) AND the
          # navigable HTML report (playwright-report/, built by --reporter=list,html).
          # Without the report dir the most useful triage artifact for a remote CI
          # failure is built on every run and then discarded at runner teardown (WR-02).
          path: |
            apps/pwa/test-results/
            apps/pwa/playwright-report/
          retention-days: 14

  security:
    runs-on: ubuntu-latest
    needs: [changes]
    if: github.event_name == 'pull_request'
    # Runs in PARALLEL with fast-checks (D-15). gitleaks always runs (D-12 — secrets
    # can appear in doc-only commits). pnpm audit + pnpm outdated run only on
    # code/lockfile-changing PRs (step-level if: keeps the job always-running).
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required: base.sha must be locally available for git log range (Pitfall 3)

      # ── Probe PR base/head SHA with merge-base fallback (A2 / OQ-1) ──────────
      # github.event.pull_request.base.sha may be empty on some Gitea versions.
      # If so, fall back to git merge-base to compute the real branch-point SHA.
      - name: Probe PR base/head SHA
        run: |
          set -euo pipefail
          echo "Event base.sha: ${{ github.event.pull_request.base.sha }}"
          echo "Event head.sha: ${{ github.event.pull_request.head.sha }}"
          BASE_SHA="${{ github.event.pull_request.base.sha }}"
          HEAD_SHA="${{ github.event.pull_request.head.sha }}"
          if [ -z "$BASE_SHA" ]; then
            echo "base.sha empty — computing merge-base fallback"
            BASE_SHA=$(git merge-base "$(git rev-parse origin/${{ github.base_ref }})" HEAD)
            echo "Computed BASE_SHA via merge-base: $BASE_SHA"
          fi
          echo "BASE_SHA=$BASE_SHA" >> "$GITHUB_ENV"
          echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV"

      # ── Gitleaks (always runs, D-12) ─────────────────────────────────────────
      - name: Install gitleaks
        run: |
          set -euo pipefail
          VERSION=8.30.1
          curl -sL \
            "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
            | tar -xz gitleaks
          chmod +x gitleaks
          mv gitleaks /usr/local/bin/gitleaks

      - name: Secret scan (PR diff, blocking)
        run: |
          set -euo pipefail
          gitleaks git \
            --log-opts="--no-merges ${BASE_SHA}..${HEAD_SHA}" \
            --config .gitleaks.toml \
            --baseline-path scripts/gitleaks-baseline.json \
            --report-path /tmp/gitleaks-pr-report.json \
            --exit-code 1

      # ── pnpm audit + outdated (code-change PRs only, D-12) ───────────────────
      # actions/cache@v4 intentionally omitted — same reasoning as fast-checks job (D-PROBE-04).

      - uses: actions/setup-node@v4
        if: needs.changes.outputs.code == 'true'
        with:
          node-version: '22'

      - name: Enable pnpm
        if: needs.changes.outputs.code == 'true'
        run: corepack enable pnpm

      - name: Install dependencies
        if: needs.changes.outputs.code == 'true'
        run: pnpm install --frozen-lockfile

      - name: Dependency audit (blocking on High+Critical)
        if: needs.changes.outputs.code == 'true'
        run: node scripts/check-audit.mjs

      - name: Dependency outdated report (advisory only)
        if: needs.changes.outputs.code == 'true'
        run: node scripts/check-outdated.mjs
        # Always exits 0 — log output only, never gates (D-06)

  gate:
    runs-on: ubuntu-latest
    needs: [fast-checks, changes, api, harness, security]
    if: always()
    steps:
      - name: Check all required jobs passed or were skipped
        run: |
          # fast-checks always runs — must be success
          if [ "${{ needs.fast-checks.result }}" != "success" ]; then
            echo "fast-checks: ${{ needs.fast-checks.result }}"
            exit 1
          fi
          # security always runs (gitleaks fires on every PR, D-12) — must be success.
          # NOT folded into the success-or-skipped loop below — security can never be skipped.
          # NOTE: individual needs.X.result check (not wildcard) due to Gitea #31007.
          if [ "${{ needs.security.result }}" != "success" ]; then
            echo "security: ${{ needs.security.result }}"
            exit 1
          fi
          # api and harness are conditionally skipped — success OR skipped are both acceptable
          # NOTE: uses individual needs.X.result checks (not the wildcard aggregate) due to
          # Gitea 1.26.2 bug #31007 where the wildcard expression returns false even when jobs succeed.
          for result in "${{ needs.api.result }}" "${{ needs.harness.result }}"; do
            if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then
              echo "Heavy job failed or was cancelled: $result"
              exit 1
            fi
          done
          echo "Gate passed."
