name: CI

on:
  # dev is deliberately not a push trigger: while a dev -> main PR is open every
  # push to dev already fires pull_request(synchronize), so listing it here ran
  # the whole suite twice for one push. Use workflow_dispatch for a dev branch
  # with no PR open yet.
  push:
    branches: [main]
  pull_request:
    branches: [main, dev]
  workflow_dispatch:

# Cancel in-progress runs for the same branch/PR
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  server-build-test:
    name: Server Build & Test (${{ matrix.os }})
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: windows-latest
            binary: chatserver.exe
          - os: ubuntu-latest
            binary: chatserver
    runs-on: ${{ matrix.os }}
    defaults:
      run:
        working-directory: Server/
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
        with:
          go-version: "1.26"
          cache-dependency-path: Server/go.sum

      - name: Build server
        run: go build -o ${{ matrix.binary }} -ldflags "-s -w" .

      # Phase B + C build-tag matrix. Each tag variant must compile so the
      # tag boundaries don't drift.
      - name: Build with -tags otel (Phase B Step 8)
        run: go build -tags otel ./...
      - name: Build with -tags wazero (Phase C Step 9)
        run: go build -tags wazero ./...
      - name: Build with -tags otel,wazero (full community-hub build)
        run: go build -tags otel,wazero ./...

      - name: Go vulnerability check
        run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 && govulncheck ./...

      # Generated sqlc output must never drift from db/queries/. One leg of
      # the matrix is enough; make is not guaranteed on the Windows runner.
      - name: Verify generated sqlc output (make sqlc-verify)
        if: matrix.os == 'ubuntu-latest'
        run: make sqlc-install sqlc-verify

      # Protocol message-type constants (Go + TS) must never drift from
      # docs/protocol-schema.json — the single source of truth.
      - name: Verify generated protocol constants (make protocol-verify)
        if: matrix.os == 'ubuntu-latest'
        run: make protocol-verify

      - name: Run tests with race detection and coverage
        run: go test -race -timeout 20m ./... -coverprofile=coverage.out -cover

      - name: Run tests with deadlock detection
        run: go test -tags deadlock -count=1 ./...

      - name: Upload Go coverage
        if: always()
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: go-coverage-${{ matrix.os }}
          path: Server/coverage.out
          retention-days: 7

      - name: Lint
        uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
        with:
          version: v2.11.3
          working-directory: Server/

  client-check:
    name: Client Static Checks
    runs-on: windows-latest
    defaults:
      run:
        working-directory: Client/tauri-client/
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
          node-version: 20
          cache: npm
          cache-dependency-path: Client/tauri-client/package-lock.json

      - name: Install npm dependencies
        run: npm ci

      - name: Patch auto-generated Tauri TypeScript bindings
        working-directory: Client/tauri-client/
        # tauri-typegen generates an Event type that is intentionally unused in app code.
        # Rename it to _Event so @typescript-eslint/no-unused-vars does not fail.
        run: |
          node -e "
          const fs = require('fs');
          const p = 'src/generated/events.ts';
          if (fs.existsSync(p)) {
            let c = fs.readFileSync(p, 'utf8');
            c = c.replace(/^type Event\b/gm, 'type _Event').replace(/^interface Event\b/gm, 'interface _Event');
            c = c.replace(/,\s*type Event\s*(?=\})/g, ' '); // unused named import from @tauri-apps/api/event
            fs.writeFileSync(p, c);
            console.log('Patched: renamed Event -> _Event in generated/events.ts');
          } else {
            console.log('src/generated/events.ts not found, skipping patch.');
          }
          "

      # Scoped to shipped dependencies. The remaining high findings are all one
      # advisory, brace-expansion <=5.0.7, reaching us only through dev tooling
      # (eslint, @vitest/coverage-v8, stryker). Those are already on their
      # latest versions, so no bump reaches the fix, and there is no patched
      # release in the 1.x/2.x lines they pin. Forcing every copy to 5.0.9 via
      # overrides was tried and broke the build: minimatch requires
      # brace-expansion as CJS and v5 is not callable that way, which took out
      # vitest's coverage provider. Nothing here ships to users; revisit when
      # eslint and @vitest/coverage-v8 widen their minimatch ranges.
      - name: Security audit (npm, shipped deps)
        run: npm audit --omit=dev --audit-level=high

      - name: Oxlint (fast correctness checks)
        run: npx oxlint src/

      - name: TypeScript check
        run: npx tsc --noEmit

      - name: ESLint (type-aware rules)
        run: npx eslint src/

      - name: Prettier format check
        run: npx prettier --check "src/**/*.ts" "tests/**/*.ts"

      - name: Knip (unused code & deps)
        run: npx knip || true

  # Unit tests live in their own job so a suite failure is visible as exactly one
  # failing check instead of masking the static gates above. The suite is GREEN
  # and must stay green — never "fix" a failing test by editing its assertions.
  client-tests:
    name: Client Unit Tests
    runs-on: windows-latest
    defaults:
      run:
        working-directory: Client/tauri-client/
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
          node-version: 20
          cache: npm
          cache-dependency-path: Client/tauri-client/package-lock.json

      - name: Install npm dependencies
        run: npm ci

      - name: Run unit tests with coverage
        run: npx vitest run --coverage --reporter=default

      - name: Upload client coverage
        if: always()
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: client-coverage
          path: Client/tauri-client/coverage/
          retention-days: 7

  # Rust unit tests used to live inside tauri-build, which only runs on PRs to
  # main — so #[cfg(test)] code never ran on pushes or on PRs to dev, and could
  # rot for a whole release cycle. This job runs them on every event. Clippy is
  # run with --all-targets here (tauri-build's lib-only clippy skips test code).
  rust-tests:
    name: Rust Unit Tests
    runs-on: ubuntu-22.04
    timeout-minutes: 30
    defaults:
      run:
        working-directory: Client/tauri-client/src-tauri/
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Install Linux system dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            libwebkit2gtk-4.1-dev \
            libgtk-3-dev \
            libayatana-appindicator3-dev \
            libsecret-1-dev \
            libdbus-1-dev \
            libasound2-dev \
            libssl-dev \
            librsvg2-dev

      - name: Install Rust
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
        with:
          components: clippy

      - name: Rust cache
        uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
        with:
          workspaces: Client/tauri-client/src-tauri

      - name: Clippy lint (including test targets)
        run: cargo clippy --all-targets -- -D warnings

      - name: Rust unit tests
        run: cargo test --lib

  # Playwright e2e against the mocked-Tauri dev server. The suite is green
  # since the mock repair (start_http_proxy stub + voice-premise rewrite):
  # a full 255-test run passes locally in ~7.5 min at 1 worker. Runaway
  # protection lives in playwright.config.ts (maxFailures: 20 aborts a
  # systemic cascade early; globalTimeout: 20 min self-terminates with a
  # usable report) with timeout-minutes below as the outer backstop.
  #
  # Still continue-on-error for now: a newly-revived 255-test browser suite
  # may harbor rare flakes (retries: 2 covers them, but confidence needs a
  # few green pushes first). Flip this job to blocking once it has been
  # stably green across several pushes.
  # See docs/audit-test-coverage-2026-07-25.md T-2026-07-25-21.
  # The native config (playwright.config.native.ts) is deliberately not wired
  # up — it needs a real server and a built desktop binary.
  client-e2e:
    name: Client E2E (Playwright, non-blocking)
    runs-on: ubuntu-latest
    continue-on-error: true
    timeout-minutes: 25
    defaults:
      run:
        working-directory: Client/tauri-client/
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
          node-version: 20
          cache: npm
          cache-dependency-path: Client/tauri-client/package-lock.json

      - name: Install npm dependencies
        run: npm ci

      - name: Install Playwright browser
        run: npx playwright install --with-deps chromium

      - name: Run Playwright tests
        run: npx playwright test --config=playwright.config.ts

      - name: Upload Playwright report
        if: always()
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: playwright-report
          path: |
            Client/tauri-client/playwright-report/
            Client/tauri-client/test-results/
          retention-days: 7

  # Image build is verification only, so it is skipped on dev to keep day-to-day
  # work on the fast check suite. Runs for main pushes and PRs targeting main.
  server-docker-build:
    name: Server Docker Build (verify)
    if: github.ref_name == 'main' || github.base_ref == 'main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0

      - name: Build image (no push)
        uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
        with:
          context: Server/
          push: false
          build-args: VERSION=ci
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier)
  tauri-build:
    name: Tauri Full Build (${{ matrix.os }})
    needs: client-check
    if: github.event_name == 'pull_request' && github.base_ref == 'main'
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: windows-latest
          - os: ubuntu-22.04
          - os: ubuntu-22.04-arm
    runs-on: ${{ matrix.os }}
    defaults:
      run:
        working-directory: Client/tauri-client/
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
          node-version: 20
          cache: npm
          cache-dependency-path: Client/tauri-client/package-lock.json

      - name: Install Linux system dependencies
        if: startsWith(matrix.os, 'ubuntu')
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            libwebkit2gtk-4.1-dev \
            libgtk-3-dev \
            libayatana-appindicator3-dev \
            libsecret-1-dev \
            libdbus-1-dev \
            libasound2-dev \
            libssl-dev \
            patchelf \
            librsvg2-dev \
            xdg-utils

      - name: Install Rust
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
        with:
          components: clippy

      - name: Rust cache
        uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
        with:
          workspaces: Client/tauri-client/src-tauri

      - name: Install npm dependencies
        run: npm ci

      - name: Install tauri-typegen
        run: cargo install tauri-typegen@0.5.0 --quiet

      - name: Generate TypeScript IPC bindings
        working-directory: Client/tauri-client/
        run: cargo tauri-typegen generate

      - name: Fix generated TypeScript bindings (tauri-typegen 0.5.0 workaround)
        working-directory: Client/tauri-client/
        # tauri-typegen 0.5.0 cannot map serde_json::Value to a TS type — patch post-generation.
        # Duplicate events are avoided at source by using one emit() call site per event name.
        run: |
          node -e "
          const fs = require('fs');
          const tp = fs.readFileSync('src/generated/types.ts', 'utf8');
          if (!tp.includes('export type Value')) {
            fs.writeFileSync('src/generated/types.ts', tp.replace(
              'export interface CredentialData',
              'export type Value = unknown;\n\nexport interface CredentialData'
            ));
          }
          console.log('Generated bindings patched.');
          "

      - name: Clippy lint (Rust)
        working-directory: Client/tauri-client/src-tauri/
        run: cargo clippy -- -D warnings

      # Rust unit tests moved to the standalone `rust-tests` job so they run on
      # every event, not just PRs to main.

      - name: Security audit (Rust dependencies)
        working-directory: Client/tauri-client/src-tauri/
        run: |
          cargo install cargo-audit@0.22.1 --quiet
          cargo audit

      - name: Build Tauri app
        env:
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
        run: npm run tauri build
