name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  # ─── Unit & Browser Component Tests ──────────────────────────────────────────
  # Runs inside the official Playwright Docker image — Chromium and all system
  # deps are pre-installed, so no install or cache step is needed for the browser.
  unit-tests:
    name: Unit & Component Tests
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.60.0-noble
    steps:
      - uses: actions/checkout@v4

      - name: Cache node_modules
        id: node-modules-cache
        uses: actions/cache@v4
        with:
          path: frontend/node_modules
          key: node-modules-${{ hashFiles('frontend/package-lock.json') }}

      - name: Install dependencies
        if: steps.node-modules-cache.outputs.cache-hit != 'true'
        run: npm ci
        working-directory: frontend

      - name: Security audit (no dev deps)
        run: npm audit --audit-level=high --omit=dev
        working-directory: frontend

      - name: Compile Paraglide i18n
        run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
        working-directory: frontend

      - name: Sync SvelteKit
        run: npx svelte-kit sync
        working-directory: frontend

      - name: Lint
        run: npm run lint
        working-directory: frontend

      - name: Assert no banned vi.mock patterns
        shell: bash
        run: |
          # Literal pdfjs-dist (libLoader pattern — ADR 012)
          if grep -rF "vi.mock('pdfjs-dist'" frontend/src/; then
            echo "FAIL: banned vi.mock('pdfjs-dist') pattern found — see ADR 012. Use the libLoader prop injection pattern instead."
            exit 1
          fi
          # Async factory with dynamic import in body (named mechanism — ADR 012 / #553).
          # Multiline PCRE matches `vi.mock(<arg>, async ... { ... await import(...) ... })`
          # across line breaks. __meta__ is excluded because it contains fixture strings
          # demonstrating the very pattern this check is meant to forbid.
          if grep -rPzln 'vi\.mock\([^)]+,\s*async[^{]*\{[\s\S]*?await\s+import\s*\(' \
              --include='*.spec.ts' --include='*.test.ts' \
              --exclude-dir='__meta__' \
              frontend/src/; then
            echo "FAIL: banned async vi.mock factory with dynamic import in body — see ADR 012 / #553. Use a synchronous factory + vi.hoisted instead."
            exit 1
          fi

      - name: Assert no (upload|download)-artifact past v3
        shell: bash
        run: |
          # Self-test: verify the regex catches v4+ and does not catch v3.
          tmp=$(mktemp)
          printf '        uses: actions/upload-artifact@v5\n' > "$tmp"
          grep -qP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
            || { echo "FAIL: guard self-test — regex missed upload-artifact@v5"; rm "$tmp"; exit 1; }
          printf '        uses: actions/upload-artifact@v3\n' > "$tmp"
          grep -qvP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
            || { echo "FAIL: guard self-test — regex incorrectly flagged upload-artifact@v3"; rm "$tmp"; exit 1; }
          rm "$tmp"
          # Guard: Gitea Actions (act_runner) does not implement the v4 artifact protocol.
          # Both upload-artifact and download-artifact share the same incompatibility.
          # Pin to @v3. See ADR-014 / #557.
          if grep -RPn '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' .gitea/workflows/; then
            echo "::error::actions/(upload|download)-artifact@v4+ is unsupported on Gitea Actions (act_runner). Pin to @v3. See ADR-014 / #557."
            exit 1
          fi

      - name: Run unit and component tests with coverage
        shell: bash
        run: |
          set -eo pipefail
          npm run test:coverage 2>&1 | tee /tmp/coverage-test-${{ github.run_id }}.log
        working-directory: frontend
        env:
          TZ: Europe/Berlin

      # Diagnostic guard: covers the coverage run only. If `npm test` (above)
      # exits 1 with a birpc error, the named pattern appears here — not there.
      - name: Assert no birpc teardown race in coverage run
        shell: bash
        if: always()
        run: |
          if grep -qF "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log 2>/dev/null; then
            echo "FAIL: [birpc] rpc is closed teardown race detected in coverage run"
            grep -F "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log
            exit 1
          fi

      # Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
      - name: Upload coverage reports
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: coverage-reports
          path: |
            frontend/coverage/
            /tmp/coverage-test-${{ github.run_id }}.log

      - name: Build frontend
        run: npm run build
        working-directory: frontend

      # ── Prerender output is exactly the public help page ───────────────────
      # SvelteKit prerender + crawl follows nav links and bakes "redirect to
      # /login" HTML for every protected route, served BEFORE runtime hooks
      # (see #514). With `crawl: false` only the explicit entry should land
      # in build/prerendered/. Anything else is a regression — fail the build.
      - name: Assert prerender output is only /hilfe/transkription
        run: |
          cd frontend
          set -e
          extra=$(find build/prerendered -type f \
                  -not -path 'build/prerendered/hilfe/*' \
                  -not -name '*.br' -not -name '*.gz' \
                  || true)
          if [ -n "$extra" ]; then
            echo "FAIL: unexpected prerendered files (would shadow runtime hooks):"
            echo "$extra"
            exit 1
          fi
          # And the help page must still be there.
          test -f build/prerendered/hilfe/transkription.html \
            || { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; }
          echo "PASS: only /hilfe/transkription.html prerendered."

      # Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
      - name: Upload screenshots
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: unit-test-screenshots
          path: frontend/test-results/screenshots/

  # ─── OCR Service Unit Tests ───────────────────────────────────────────────────
  # Only stdlib/lightweight tests — no ML stack (PyTorch/Surya/Kraken) required.
  # test_tmpdir.py covers the TMPDIR env var and entrypoint mkdir behaviour (ADR-021).
  # test_tmpdir_is_inside_persistent_cache_volume is skipped in CI (TMPDIR not
  # set to /app/cache here); it runs inside the deployed Docker container.
  ocr-tests:
    name: OCR Service Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install test dependencies
        run: pip install "pyspellchecker==0.9.0" "fastapi==0.115.6" pytest pytest-asyncio
        working-directory: ocr-service

      - name: Run OCR unit tests (no ML stack required)
        run: python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py test_tmpdir.py -v
        working-directory: ocr-service

  # ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
  # Pure Mockito + WebMvcTest — no DB or S3 needed.
  backend-unit-tests:
    name: Backend Unit Tests
    runs-on: ubuntu-latest
    env:
      DOCKER_API_VERSION: "1.43"  # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
      DOCKER_HOST: unix:///var/run/docker.sock
      TESTCONTAINERS_RYUK_DISABLED: "true"
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: temurin

      - name: Cache Maven repository
        uses: actions/cache@v4
        with:
          path: ~/.m2/repository
          key: maven-${{ hashFiles('backend/pom.xml') }}
          restore-keys: maven-

      - name: Run backend tests
        run: |
          chmod +x mvnw
          ./mvnw clean verify
        working-directory: backend

      - name: Upload surefire reports
        if: always()
        # Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
        uses: actions/upload-artifact@v3
        with:
          name: surefire-reports
          path: backend/target/surefire-reports/

  # ─── fail2ban Regex Regression ────────────────────────────────────────────────
  # The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
  # the JSON keys would silently break it (fail2ban-regex would return
  # "0 matches", fail2ban would stop banning, no error surface). This job
  # pins the contract against a deterministic sample line.
  fail2ban-regex:
    name: fail2ban Regex
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install fail2ban
        run: |
          sudo apt-get update
          sudo apt-get install -y fail2ban

      - name: Matches /api/auth/login 401
        run: |
          echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":401}' > /tmp/sample.log
          out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
          echo "$out"
          echo "$out" | grep -qE '1 matched' \
            || { echo "expected 1 match for /api/auth/login 401"; exit 1; }

      - name: Matches /api/auth/login 429
        run: |
          echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":429}' > /tmp/sample.log
          out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
          echo "$out"
          echo "$out" | grep -qE '1 matched' \
            || { echo "expected 1 match for /api/auth/login 429"; exit 1; }

      - name: Matches /api/auth/forgot-password 401
        run: |
          echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/forgot-password"},"status":401}' > /tmp/sample.log
          out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
          echo "$out"
          echo "$out" | grep -qE '1 matched' \
            || { echo "expected 1 match for /api/auth/forgot-password 401"; exit 1; }

      - name: Does not match /api/auth/login 200
        run: |
          echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":200}' > /tmp/sample.log
          out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
          echo "$out"
          echo "$out" | grep -qE '0 matched' \
            || { echo "expected 0 matches for /api/auth/login 200"; exit 1; }

      - name: Does not match /api/documents (unrelated 401)
        run: |
          echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"GET","host":"archiv.raddatz.cloud","uri":"/api/documents"},"status":401}' > /tmp/sample.log
          out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
          echo "$out"
          echo "$out" | grep -qE '0 matched' \
            || { echo "expected 0 matches for /api/documents 401"; exit 1; }

      # ── Backend resolves to file-polling, not systemd ─────────────────────
      # The Debian/Ubuntu fail2ban package ships defaults-debian.conf with
      # `[DEFAULT] backend = systemd`. Without `backend = polling` in our
      # jail, the daemon loads the jail but reads from journald and never
      # touches /var/log/caddy/access.log — i.e. the regex above passes in
      # isolation while the live jail is inert. See issue #503.
      - name: Jail resolves with polling backend (not inherited systemd)
        run: |
          sudo ln -sfn "$PWD/infra/fail2ban/jail.d/familienarchiv.conf"       /etc/fail2ban/jail.d/familienarchiv.conf
          sudo ln -sfn "$PWD/infra/fail2ban/filter.d/familienarchiv-auth.conf" /etc/fail2ban/filter.d/familienarchiv-auth.conf
          dump=$(sudo fail2ban-client -d 2>&1)
          echo "$dump" | grep -E "add.*familienarchiv-auth" || true
          echo "$dump" | grep -qE "\['add', 'familienarchiv-auth', 'polling'\]" \
            || { echo "FAIL: familienarchiv-auth jail did not resolve to 'polling' backend"; exit 1; }

  # ─── Semgrep Security Scan ───────────────────────────────────────────────────
  # Catches XXE-unprotected XML parser factories and similar patterns defined in
  # .semgrep/security.yml. Runs in parallel with backend-unit-tests for fast feedback.
  # Uses local rules only (no SEMGREP_APP_TOKEN / OIDC — act_runner does not support it).
  semgrep-scan:
    name: Semgrep Security Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'

      - name: Install Semgrep
        run: pip install semgrep==1.163.0

      - name: Run security rules
        run: semgrep --config .semgrep/security.yml --error --metrics=off backend/src/

  # ─── Compose Bucket-Bootstrap Idempotency ─────────────────────────────────────
  # docker-compose.prod.yml's create-buckets service runs on every
  # `docker compose up` (one-shot, no restart). Must be idempotent — a
  # re-deploy must not fail just because the bucket / user / policy
  # already exists. Validated by running create-buckets twice against a
  # throwaway minio stack and asserting both invocations exit 0.
  compose-idempotency:
    name: Compose Bucket Idempotency
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Write stub env file
        run: |
          cat > .env.test <<'EOF'
          TAG=test
          PORT_BACKEND=18080
          PORT_FRONTEND=13000
          APP_DOMAIN=localhost
          POSTGRES_PASSWORD=stub
          MINIO_PASSWORD=stubrootpassword
          MINIO_APP_PASSWORD=stubapppassword
          OCR_TRAINING_TOKEN=stub
          APP_ADMIN_USERNAME=admin@local
          APP_ADMIN_PASSWORD=stub
          MAIL_HOST=mailpit
          MAIL_PORT=1025
          APP_MAIL_FROM=noreply@local
          IMPORT_HOST_DIR=/tmp/dummy-import
          COMPOSE_NETWORK_NAME=test-idem-archiv-net
          EOF

      - name: Bring up minio
        run: |
          docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test up -d --wait minio

      - name: First create-buckets run
        run: |
          docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test run --rm create-buckets

      - name: Second create-buckets run (idempotency check)
        run: |
          docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test run --rm create-buckets

      - name: Teardown
        if: always()
        run: |
          docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test down -v
          rm -f .env.test