name: PR

on:
  pull_request:
    branches: [main]

concurrency:
  group: zync-shared-pr-preview
  cancel-in-progress: false

jobs:
  check:
    name: Typecheck + Lint + Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - run: rm .platform
      - uses: actions/checkout@v7
        with:
          repository: platform-modules/mod
          ref: 8ff74af8a1433f0a864e07ec4cd765d412086c0f
          path: .platform
          token: ${{ secrets.PLATFORM_REPO_TOKEN }}

      - uses: pnpm/action-setup@v6
        with:
          version: 10.33.0

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: pnpm

      - name: Install platform dependencies
        working-directory: .platform
        run: corepack pnpm install --config.confirmModulesPurge=false --frozen-lockfile

      - name: Build platform modules
        working-directory: .platform
        run: corepack pnpm run build

      - name: Install dependencies
        run: pnpm install --config.confirmModulesPurge=false --frozen-lockfile

      - name: Audit (fail on high/critical CVEs)
        run: pnpm dlx pnpm@11.0.0 --pm-on-fail=ignore audit --audit-level=high

      - name: Typecheck + Lint + Test
        run: pnpm turbo typecheck lint test --concurrency=1

      - name: QA registry contract
        run: pnpm --filter zync-app test:qa:registry

      - name: QA fixture contract
        run: pnpm --filter zync-app test:qa:fixtures

      - name: QA diagnostics contract
        run: pnpm --filter zync-app test:qa:diagnostics

      - name: QA journey and diagnostics typecheck
        run: pnpm --filter zync-app typecheck:qa

      - name: QA CI and count contracts
        run: |
          pnpm --filter zync-app test:qa:ci
          pnpm --filter zync-app test:qa:count

  deploy-preview:
    name: Deploy Preview
    if: ${{ vars.ZYNC_PREVIEW_DEPLOY == 'true' }}
    runs-on: ubuntu-latest
    needs: check
    environment: preview
    outputs:
      preview_url: ${{ steps.deploy_app.outputs.preview_url }}
    steps:
      - uses: actions/checkout@v7

      - run: rm .platform
      - uses: actions/checkout@v7
        with:
          repository: platform-modules/mod
          ref: 8ff74af8a1433f0a864e07ec4cd765d412086c0f
          path: .platform
          token: ${{ secrets.PLATFORM_REPO_TOKEN }}

      - uses: pnpm/action-setup@v6
        with:
          version: 10.33.0

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: pnpm

      - name: Install platform dependencies
        working-directory: .platform
        run: corepack pnpm install --config.confirmModulesPurge=false --frozen-lockfile

      - name: Build platform modules
        working-directory: .platform
        run: corepack pnpm run build

      - name: Install dependencies
        run: pnpm install --config.confirmModulesPurge=false --frozen-lockfile

      - name: Build all
        run: pnpm turbo build

      - name: Build isolated zync-api preview config
        env:
          PREVIEW_CLOUDFLARE_RESOURCES: ${{ secrets.PREVIEW_CLOUDFLARE_RESOURCES }}
        run: |
          node <<'NODE'
          const fs = require('node:fs')
          const requiredKeys = [
            'hyperdriveId', 'kvNamespaceId', 'storageBucketName', 'receiptsBucketName',
            'invoiceSnapshotsBucketName', 'portalFilesBucketName', 'jobsQueueName',
            'auditQueueName', 'expenseQueueName', 'supportReplyDispatchQueueName',
            'realtimeQueueName', 'vectorizeIndexName',
          ]
          const rawResources = process.env.PREVIEW_CLOUDFLARE_RESOURCES
          if (!rawResources) throw new Error('Missing PREVIEW_CLOUDFLARE_RESOURCES')
          let resources
          try { resources = JSON.parse(rawResources) } catch { throw new Error('PREVIEW_CLOUDFLARE_RESOURCES must be valid JSON') }
          const resourceKeys = Object.keys(resources).sort()
          if (resourceKeys.join(',') !== [...requiredKeys].sort().join(',')) {
            throw new Error(`PREVIEW_CLOUDFLARE_RESOURCES must contain exactly: ${requiredKeys.join(', ')}`)
          }
          for (const key of requiredKeys) {
            if (typeof resources[key] !== 'string' || !resources[key].trim()) throw new Error(`${key} must be a non-empty string`)
          }

          const checkedInConfig = fs.readFileSync('apps/zync-api/wrangler.toml', 'utf8')
          const productionValues = new Set([
            ...checkedInConfig.matchAll(/^id\s*=\s*"([^"]+)"\s*$/gm),
            ...checkedInConfig.matchAll(/^namespace_id\s*=\s*"([^"]+)"\s*$/gm),
            ...checkedInConfig.matchAll(/^(?:bucket_name|queue|index_name)\s*=\s*"([^"]+)"\s*$/gm),
          ].map((match) => match[1]))
          for (const [key, value] of Object.entries(resources)) {
            if (productionValues.has(value)) throw new Error(`${key} must not reuse checked-in production resources`)
          }

          const config = {
            name: 'zync-api-preview',
            main: 'src/index.ts',
            compatibility_date: '2026-05-30',
            compatibility_flags: ['nodejs_compat'],
            workers_dev: true,
            placement: { mode: 'smart' },
            vars: {
              ENVIRONMENT: 'preview',
              FIREBASE_PROJECT_ID: 'zync-dev-dd678',
              APP_BASE_URL: 'https://zync-app-preview.dry-salad-ffa1.workers.dev',
            },
            hyperdrive: [{ binding: 'DB', id: resources.hyperdriveId }],
            r2_buckets: [
              { binding: 'STORAGE', bucket_name: resources.storageBucketName },
              { binding: 'RECEIPTS_BUCKET', bucket_name: resources.receiptsBucketName },
              { binding: 'INVOICE_SNAPSHOTS_BUCKET', bucket_name: resources.invoiceSnapshotsBucketName },
              { binding: 'PORTAL_FILES_BUCKET', bucket_name: resources.portalFilesBucketName },
            ],
            kv_namespaces: [
              { binding: 'KV', id: resources.kvNamespaceId },
              { binding: 'RATELIMIT_KV', id: resources.kvNamespaceId },
              { binding: 'STATUS_KV', id: resources.kvNamespaceId },
            ],
            queues: {
              producers: [
                { binding: 'QUEUE', queue: resources.jobsQueueName },
                { binding: 'AUDIT_QUEUE', queue: resources.auditQueueName },
                { binding: 'EXPENSE_QUEUE', queue: resources.expenseQueueName },
                { binding: 'SUPPORT_REPLY_DISPATCH_QUEUE', queue: resources.supportReplyDispatchQueueName },
                { binding: 'REALTIME_QUEUE', queue: resources.realtimeQueueName },
              ],
            },
            ai: { binding: 'AI' },
            durable_objects: {
              bindings: [
                { name: 'DO_REALTIME', class_name: 'TenantRealtimeDO' },
                { name: 'PWHASH_DO', class_name: 'PasswordHashDO' },
                { name: 'AUTHWRITE_DO', class_name: 'AuthWriteDO' },
                { name: 'RECURRING_ALARM_DO', class_name: 'RecurringInvoiceAlarmDO' },
              ],
            },
              migrations: [
                { tag: 'v1', new_sqlite_classes: ['TenantRealtimeDO'] },
                { tag: 'v2', new_sqlite_classes: ['PasswordHashDO'] },
                { tag: 'v3', new_sqlite_classes: ['AuthWriteDO'] },
                { tag: 'v4', new_sqlite_classes: ['RecurringInvoiceAlarmDO'] },
                { tag: 'v5', new_sqlite_classes: ['ApiRuntimeDO'] },
                { tag: 'v6', deleted_classes: ['ApiRuntimeDO'] },
            ],
            vectorize: [{ binding: 'VECTORIZE', index_name: resources.vectorizeIndexName }],
            analytics_engine_datasets: [
              { binding: 'ANALYTICS_ENGINE', dataset: 'zync-preview-analytics' },
              { binding: 'API_USAGE', dataset: 'zync-preview-api-usage' },
            ],
            unsafe: {
              bindings: [
                { type: 'ratelimit', name: 'RATE_LIMITER_AUTH', namespace_id: '9002001', simple: { limit: 5, period: 60 } },
                { type: 'ratelimit', name: 'RATE_LIMITER_WEBHOOK', namespace_id: '9002002', simple: { limit: 100, period: 60 } },
                { type: 'ratelimit', name: 'RATE_LIMITER_EXPENSE_UPLOAD', namespace_id: '9002003', simple: { limit: 10, period: 60 } },
                { type: 'ratelimit', name: 'RATE_LIMITER_LEAD_FORM', namespace_id: '9002004', simple: { limit: 20, period: 60 } },
                { type: 'ratelimit', name: 'RATE_LIMITER_SEARCH', namespace_id: '9002005', simple: { limit: 60, period: 60 } },
                { type: 'ratelimit', name: 'RATE_LIMITER_SIGN', namespace_id: '9002101', simple: { limit: 20, period: 60 } },
                { type: 'ratelimit', name: 'RATE_LIMITER_PROPOSAL', namespace_id: '9002102', simple: { limit: 10, period: 60 } },
                { type: 'ratelimit', name: 'RATE_LIMITER_CATALOG', namespace_id: '9002103', simple: { limit: 30, period: 60 } },
              ],
            },
          }
          fs.writeFileSync('apps/zync-api/.wrangler-preview.json', JSON.stringify(config))
          NODE

      - name: Deploy zync-api preview
        run: pnpm --filter zync-api exec wrangler deploy --config .wrangler-preview.json
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

      - name: Set zync-api preview secrets
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          E2E_FIXTURE_SECRET: ${{ secrets.E2E_FIXTURE_SECRET }}
          PREVIEW_JWT_SECRET: ${{ secrets.PREVIEW_JWT_SECRET }}
          PREVIEW_PWHASH_DO_SECRET: ${{ secrets.PREVIEW_PWHASH_DO_SECRET }}
        run: |
          node <<'NODE'
          const secrets = {
            E2E_FIXTURE_SECRET: process.env.E2E_FIXTURE_SECRET,
            PREVIEW_JWT_SECRET: process.env.PREVIEW_JWT_SECRET,
            PREVIEW_PWHASH_DO_SECRET: process.env.PREVIEW_PWHASH_DO_SECRET,
          }
          for (const [name, value] of Object.entries(secrets)) {
            if (!value || Array.from(value).length < 32) {
              throw new Error(`${name} must contain at least 32 characters`)
            }
          }
          if (new Set(Object.values(secrets)).size !== Object.keys(secrets).length) {
            throw new Error('Preview secrets must be pairwise distinct')
          }
          NODE
          pnpm --filter zync-api exec wrangler whoami >/dev/null
          node -e "process.stdout.write(JSON.stringify({E2E_FIXTURE_SECRET:process.env.E2E_FIXTURE_SECRET,JWT_SECRET:process.env.PREVIEW_JWT_SECRET,PWHASH_DO_SECRET:process.env.PREVIEW_PWHASH_DO_SECRET}))" |
            pnpm --filter zync-api exec wrangler secret bulk --name zync-api-preview

      - name: Deploy zync-www preview
        run: pnpm --filter zync-www exec wrangler deploy --env preview
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

      - name: Deploy zync-app preview
        id: deploy_app
        run: |
          set -o pipefail
          output=$(pnpm --filter zync-app exec wrangler deploy --env preview 2>&1 | tee /dev/stderr)
          preview_url=$(printf '%s\n' "$output" | grep -oE 'https://[^[:space:]]+\.workers\.dev' | tail -1)
          test -n "$preview_url"
          printf 'preview_url=%s\n' "$preview_url" >> "$GITHUB_OUTPUT"
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

  e2e:
    name: Playwright E2E
    if: ${{ needs.deploy-preview.result == 'success' }}
    runs-on: ubuntu-latest
    needs: deploy-preview
    environment: preview
    steps:
      - uses: actions/checkout@v7

      - run: rm .platform
      - uses: actions/checkout@v7
        with:
          repository: platform-modules/mod
          ref: 8ff74af8a1433f0a864e07ec4cd765d412086c0f
          path: .platform
          token: ${{ secrets.PLATFORM_REPO_TOKEN }}

      - uses: pnpm/action-setup@v6
        with:
          version: 10.33.0

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: pnpm

      - name: Install platform dependencies
        working-directory: .platform
        run: corepack pnpm install --config.confirmModulesPurge=false --frozen-lockfile

      - name: Build platform modules
        working-directory: .platform
        run: corepack pnpm run build

      - name: Install dependencies
        run: pnpm install --config.confirmModulesPurge=false --frozen-lockfile

      - name: Install Playwright browsers
        run: pnpm --filter zync-app exec playwright install --with-deps chromium webkit

      - name: Run E2E tests
        run: pnpm --filter zync-app test:e2e:pr
        env:
          PLAYWRIGHT_BASE_URL: ${{ needs.deploy-preview.outputs.preview_url }}
          E2E_FIXTURE_SECRET: ${{ secrets.E2E_FIXTURE_SECRET }}
          E2E_RUN_ID: pr-${{ github.event.pull_request.number }}-${{ github.run_id }}-${{ github.run_attempt }}

      - name: Run authenticated recovery E2E
        working-directory: apps/zync-app
        run: pnpm test:e2e:recovery
        env:
          PLAYWRIGHT_BASE_URL: ${{ needs.deploy-preview.outputs.preview_url }}
          DEMO_EMAIL: ${{ secrets.DEMO_EMAIL }}
          DEMO_PASSWORD: ${{ secrets.DEMO_PASSWORD }}

      - name: Upload Playwright E2E artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-e2e-artifacts
          if-no-files-found: warn
          path: |
            apps/zync-app/playwright-report/**
            apps/zync-app/test-results/**

  os-mounted:
    name: Mounted OS Playwright
    if: ${{ needs.deploy-preview.result == 'success' }}
    runs-on: ubuntu-latest
    needs: deploy-preview
    environment: preview
    steps:
      - uses: actions/checkout@v7

      - run: rm .platform
      - uses: actions/checkout@v7
        with:
          repository: platform-modules/mod
          ref: 8ff74af8a1433f0a864e07ec4cd765d412086c0f
          path: .platform
          token: ${{ secrets.PLATFORM_REPO_TOKEN }}

      - uses: pnpm/action-setup@v6
        with:
          version: 10.33.0

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: pnpm

      - name: Install platform dependencies
        working-directory: .platform
        run: corepack pnpm install --config.confirmModulesPurge=false --frozen-lockfile

      - name: Build platform modules
        working-directory: .platform
        run: corepack pnpm run build

      - name: Install dependencies
        run: pnpm install --config.confirmModulesPurge=false --frozen-lockfile

      - name: Install Playwright browsers
        run: pnpm --filter zync-app exec playwright install --with-deps chromium webkit

      - name: Run mounted OS tests
        working-directory: apps/zync-app
        run: pnpm test:e2e:os
        env:
          PLAYWRIGHT_BASE_URL: ${{ needs.deploy-preview.outputs.preview_url }}
          DEMO_EMAIL: ${{ secrets.DEMO_EMAIL }}
          DEMO_PASSWORD: ${{ secrets.DEMO_PASSWORD }}

      - name: Upload OS QA artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: os-mounted-artifacts
          if-no-files-found: warn
          path: |
            apps/zync-app/playwright-report/**
            apps/zync-app/test-results/**

  journey-readiness:
    name: Journey matrix readiness
    if: ${{ needs.deploy-preview.result == 'success' }}
    runs-on: ubuntu-latest
    needs: deploy-preview
    outputs:
      ready: ${{ steps.cases.outputs.ready }}
    steps:
      - uses: actions/checkout@v7

      - id: cases
        shell: bash
        run: |
          if find apps/zync-app/tests/journeys/cases -type f -name '*.spec.ts' -print -quit 2>/dev/null | grep -q .; then
            echo "ready=true" >> "$GITHUB_OUTPUT"
          else
            echo "ready=false" >> "$GITHUB_OUTPUT"
          fi

  journey-shards:
    name: Journey shard ${{ matrix.shard }}/8
    if: ${{ needs.deploy-preview.result == 'success' && needs.journey-readiness.outputs.ready == 'true' }}
    runs-on: ubuntu-latest
    needs: [deploy-preview, journey-readiness]
    environment: preview
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4, 5, 6, 7, 8]
    steps:
      - uses: actions/checkout@v7

      - run: rm .platform
      - uses: actions/checkout@v7
        with:
          repository: platform-modules/mod
          ref: 8ff74af8a1433f0a864e07ec4cd765d412086c0f
          path: .platform
          token: ${{ secrets.PLATFORM_REPO_TOKEN }}

      - uses: pnpm/action-setup@v6
        with:
          version: 10.33.0

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: pnpm

      - name: Install platform dependencies
        working-directory: .platform
        run: corepack pnpm install --config.confirmModulesPurge=false --frozen-lockfile

      - name: Build platform modules
        working-directory: .platform
        run: corepack pnpm run build

      - name: Install dependencies
        run: pnpm install --config.confirmModulesPurge=false --frozen-lockfile

      - name: Install Playwright browsers
        run: pnpm --filter zync-app exec playwright install --with-deps chromium

      - name: Run normal journey shard
        working-directory: apps/zync-app
        run: pnpm test:e2e:journeys -- --shard=${{ matrix.shard }}/8
        env:
          PLAYWRIGHT_BASE_URL: ${{ needs.deploy-preview.outputs.preview_url }}
          DEMO_EMAIL: ${{ secrets.DEMO_EMAIL }}
          DEMO_PASSWORD: ${{ secrets.DEMO_PASSWORD }}
          PORTAL_TENANT_SLUG: ${{ vars.PORTAL_TENANT_SLUG }}
          PORTAL_EMAIL: ${{ secrets.PORTAL_EMAIL }}
          PORTAL_PASSWORD: ${{ secrets.PORTAL_PASSWORD }}
          CONTRACTOR_PORTAL_TOKEN: ${{ secrets.CONTRACTOR_PORTAL_TOKEN }}
          ACCOUNTANT_EXPORT_YEAR: ${{ vars.ACCOUNTANT_EXPORT_YEAR }}
          UJ_SIGNED_CONTRACT_ID: ${{ vars.UJ_SIGNED_CONTRACT_ID }}
          ADMIN_BASE_URL: ${{ vars.ADMIN_BASE_URL }}
          APP_BASE_URL: ${{ needs.deploy-preview.outputs.preview_url }}
          STATUS_BASE_URL: ${{ vars.STATUS_BASE_URL }}
          ADMIN_JOURNEY_TENANT_SLUG: ${{ vars.ADMIN_JOURNEY_TENANT_SLUG }}
          ADMIN_JOURNEY_TENANT_USER_EMAIL: ${{ secrets.ADMIN_JOURNEY_TENANT_USER_EMAIL }}
          ADMIN_EMAIL: ${{ secrets.ADMIN_EMAIL }}
          ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
          ADMIN_TOTP_SECRET: ${{ secrets.ADMIN_TOTP_SECRET }}
          SUPPORT_ADMIN_EMAIL: ${{ secrets.SUPPORT_ADMIN_EMAIL }}
          SUPPORT_ADMIN_PASSWORD: ${{ secrets.SUPPORT_ADMIN_PASSWORD }}
          SUPPORT_ADMIN_TOTP_SECRET: ${{ secrets.SUPPORT_ADMIN_TOTP_SECRET }}
          E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }}
          E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
          E2E_ADMIN_TOTP_SECRET: ${{ secrets.E2E_ADMIN_TOTP_SECRET }}
          E2E_MAILBOX_API_KEY: ${{ secrets.E2E_MAILBOX_API_KEY }}
          E2E_FIREBASE_API_KEY: ${{ secrets.E2E_FIREBASE_API_KEY }}
          E2E_FIREBASE_REFRESH_TOKEN: ${{ secrets.E2E_FIREBASE_REFRESH_TOKEN }}

      - name: Upload journey QA artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: journey-shard-${{ matrix.shard }}-artifacts
          if-no-files-found: warn
          path: |
            apps/zync-app/playwright-report/**
            apps/zync-app/test-results/**
            **/diagnostics.json
            **/*trace*.zip
            **/*screenshot*.png
            **/*video*.webm
            **/*dom*.html
