name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  merge_group:

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

jobs:
  security:
    name: Security Scan
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - name: Gitleaks
        if: github.actor != 'dependabot[bot]'
        uses: trails-cool/gitleaks-action@4cbc857b9cfa2a3297fe2be1078e196d30d1b424
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - name: Dependency audit
        run: pnpm audit --audit-level=high
        continue-on-error: true

  dockerfile-check:
    name: Dockerfile Package Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: bash scripts/check-dockerfiles.sh

  openspec:
    name: OpenSpec Validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm openspec validate --all --strict --no-interactive

  typecheck:
    name: Typecheck
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm typecheck

  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build

  visual-tests:
    name: Visual Tests
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile

      - name: Cache Playwright browsers
        id: playwright-cache
        uses: actions/cache@v6
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ hashFiles('pnpm-lock.yaml') }}

      - name: Install Playwright Chromium
        if: steps.playwright-cache.outputs.cache-hit != 'true'
        run: pnpm exec playwright install --with-deps chromium

      - name: Install Playwright deps only
        if: steps.playwright-cache.outputs.cache-hit == 'true'
        run: pnpm exec playwright install-deps chromium

      - name: Run visual regression tests
        id: visual-tests
        run: pnpm --filter @trails-cool/planner test:visual

      - name: Post diff comment on PR
        if: failure() && steps.visual-tests.outcome == 'failure' && github.event_name == 'pull_request'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          diffs=$(find apps/planner/.vitest-attachments -name "*-diff-*.png" 2>/dev/null | sort)
          if [ -z "$diffs" ]; then exit 0; fi

          artifact_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
          body="## Visual regression failures"$'\n\n'
          body+="The following tests produced screenshot diffs:"$'\n\n'
          for diff in $diffs; do
            name=$(basename "$diff" | sed 's/-diff-chromium-[a-z]*\.png//' | sed 's/-/ /g')
            body+="- \`$name\`"$'\n'
          done
          body+=$'\n'"**[Download the \`visual-snapshots-diff\` artifact]($artifact_url)** to inspect the diffs locally."$'\n\n'
          body+="To update snapshots if the change is intentional:"$'\n'
          body+="\`\`\`"$'\n'
          body+="pnpm --filter @trails-cool/planner test:visual:update"$'\n'
          body+="\`\`\`"

          gh pr comment ${{ github.event.pull_request.number }} --body "$body"

      - name: Upload screenshots on failure
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: visual-snapshots-diff
          path: apps/planner/.vitest-attachments/
          include-hidden-files: true
          retention-days: 7

  e2e:
    name: E2E Tests
    needs: build
    runs-on: ubuntu-latest
    env:
      DATABASE_URL: postgres://trails:trails@localhost:5432/trails
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile

      - name: Cache BRouter segment
        id: segment-cache
        uses: actions/cache@v6
        with:
          path: /tmp/brouter-segments
          key: brouter-segment-E10_N50-v1.7.9

      - name: Download Berlin segment
        if: steps.segment-cache.outputs.cache-hit != 'true'
        run: |
          mkdir -p /tmp/brouter-segments
          wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5

      - name: Pre-seed BRouter segment volume
        run: |
          docker volume create trails_brouter_segments
          docker run --rm \
            -v /tmp/brouter-segments:/src:ro \
            -v trails_brouter_segments:/dst \
            alpine sh -c "cp /src/*.rd5 /dst/ && chmod a+r /dst/*.rd5"

      - name: Start services
        run: docker compose -f docker-compose.dev.yml up -d --wait --build
        env:
          BROUTER_URL: http://localhost:17777

      - name: Wait for BRouter routing
        run: |
          for i in $(seq 1 60); do
            curl -s 'http://localhost:17777/brouter?lonlats=13.4,52.5|13.5,52.5&profile=trekking&format=geojson' 2>/dev/null | grep -q "FeatureCollection" && echo "BRouter ready" && break
            [ "$i" = "60" ] && echo "BRouter not ready after 120s" && exit 1
            sleep 2
          done

      - name: Push database schema
        run: pnpm db:push

      - name: Seed database
        run: pnpm db:seed

      - name: Run integration tests
        # These talk to real Postgres. The unit-test job has no DB so
        # the `*.integration.test.ts` files skip there; this job has
        # the DB up + schema pushed, so flip the gate env vars to "1"
        # and let them run. Each gate is read by one file — see
        # `runIntegration` in each test.
        #
        # --no-file-parallelism: integration tests share the journal
        # schema and clean up by `DELETE FROM ... WHERE email LIKE
        # '%@example.test'`. Parallel files step on each other's rows
        # and trip FK constraints. Running sequentially is still <3s.
        run: pnpm --filter @trails-cool/journal exec vitest run --no-file-parallelism --reporter=default app/lib/explore.integration.test.ts app/lib/follow.integration.test.ts app/lib/demo-bot.integration.test.ts app/lib/notifications.integration.test.ts app/jobs/notifications-fanout.integration.test.ts
        env:
          EXPLORE_INTEGRATION: "1"
          FOLLOW_INTEGRATION: "1"
          DEMO_BOT_INTEGRATION: "1"
          NOTIFICATIONS_INTEGRATION: "1"

      - name: Cache Playwright browsers
        id: playwright-cache
        uses: actions/cache@v6
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ hashFiles('pnpm-lock.yaml') }}

      - name: Install Playwright
        if: steps.playwright-cache.outputs.cache-hit != 'true'
        run: pnpm exec playwright install --with-deps chromium

      - name: Install Playwright deps only
        if: steps.playwright-cache.outputs.cache-hit == 'true'
        run: pnpm exec playwright install-deps chromium

      - name: Build for production
        run: pnpm build
        env:
          VITE_SENTRY_ENVIRONMENT: ci

      - name: Run E2E tests
        run: pnpm test:e2e
        env:
          BROUTER_URL: http://localhost:17777
          # E2E=true is the explicit opt-out from the fail-loud
          # requireSecret() / getDatabaseUrl() guards — playwright boots
          # the server via `react-router serve` (NODE_ENV=production) but
          # against the local dev Postgres + local cookie secrets.
          E2E: "true"
          INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }}

      - name: Playwright job summary
        if: ${{ !cancelled() }}
        run: |
          if [ -f playwright-results.json ]; then
            node -e "
              const r = require('./playwright-results.json');
              const s = r.stats;
              const dur = (s.duration / 1000).toFixed(1);
              let md = '## Playwright E2E Results\n\n';
              md += '| Status | Count |\n|--------|-------|\n';
              md += '| :white_check_mark: Passed | ' + s.expected + ' |\n';
              if (s.unexpected > 0) md += '| :x: Failed | ' + s.unexpected + ' |\n';
              if (s.flaky > 0) md += '| :warning: Flaky | ' + s.flaky + ' |\n';
              if (s.skipped > 0) md += '| :fast_forward: Skipped | ' + s.skipped + ' |\n';
              md += '| :stopwatch: Duration | ' + dur + 's |\n\n';
              for (const file of r.suites) {
                for (const describe of (file.suites || [])) {
                  md += '### ' + describe.title + '\n\n';
                  for (const spec of (describe.specs || [])) {
                    const icon = spec.ok ? ':white_check_mark:' : ':x:';
                    const t = spec.tests?.[0]?.results?.[0]?.duration;
                    md += '- ' + icon + ' ' + spec.title + (t ? ' (' + t + 'ms)' : '') + '\n';
                  }
                  md += '\n';
                }
              }
              require('fs').appendFileSync(process.env.GITHUB_STEP_SUMMARY, md);
            "
          fi

      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

  journal-image-smoke:
    # Build the journal's *production* Docker image (the `runtime` stage)
    # and actually boot it. Nothing else in CI does this: typecheck /
    # lint / test / build all run against the source tree, and the e2e
    # job boots the journal via `react-router-serve`, not the production
    # `node server.ts` entrypoint. The runtime stage copies source files
    # in by name (server.ts, app/lib, serve-static.ts, ...), so a refactor
    # that adds a file `server.ts` imports — without a matching COPY —
    # builds green everywhere and only crash-loops once deployed
    # (ERR_MODULE_NOT_FOUND). That has taken prod down more than once
    # (app/lib, app/jobs, serve-static.ts). Booting the real image and
    # hitting /api/health closes that gap: a missing static OR dynamic
    # import never reaches a healthy 200.
    name: Journal Image Smoke Test
    runs-on: ubuntu-latest
    services:
      postgres:
        image: imresamu/postgis:16-3.4
        env:
          POSTGRES_USER: trails
          POSTGRES_PASSWORD: trails
          POSTGRES_DB: trails
        ports:
          - 5432:5432
        # The postgis image restarts mid-init while it creates the
        # extension; the health check only passes once the final server
        # is up, so dependents don't race the init restart.
        options: >-
          --health-cmd "pg_isready -U trails"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 20
    env:
      DATABASE_URL: postgres://trails:trails@localhost:5432/trails
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile

      # seedOAuthClient + the demo/notifications job worker run on boot
      # and write to real tables, so the image needs a schema to come up
      # healthy. The postgis extension is auto-created by the image.
      - name: Push database schema
        run: pnpm db:push

      - name: Build journal runtime image
        run: docker build --target runtime -f apps/journal/Dockerfile -t journal-smoke .

      - name: Boot image and wait for healthy
        run: |
          # --network host: reach the service Postgres at localhost:5432
          # and publish the server on localhost:3000 in one shot.
          # E2E=true is the documented opt-out from the fail-loud
          # getDatabaseUrl() prod guard (CI points at a local Postgres).
          docker run -d --name journal-smoke --network host \
            -e NODE_ENV=production -e E2E=true \
            -e DATABASE_URL="$DATABASE_URL" \
            journal-smoke
          code=000
          for i in $(seq 1 30); do
            code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/api/health || echo 000)
            echo "attempt $i: /api/health -> $code"
            [ "$code" = "200" ] && break
            if [ "$(docker inspect -f '{{.State.Running}}' journal-smoke 2>/dev/null)" != "true" ]; then
              echo "::error::journal container exited during boot"
              break
            fi
            sleep 2
          done
          if [ "$code" != "200" ]; then
            echo "::error::journal production image failed to boot healthy (see logs below)"
            docker logs journal-smoke 2>&1 || true
            exit 1
          fi
          echo "journal production image booted healthy"

      - name: Container logs
        if: always()
        run: docker logs journal-smoke 2>&1 | tail -40 || true
