name: Build & Test

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  test:
    name: Run Swift Tests (${{ matrix.name }})
    runs-on: macos-latest
    # A hung test must fail fast, not hold a runner for GitHub's 360-minute
    # default (observed: intermittent app-suite hangs starving the queue).
    # The long steps carry tighter individual bounds (5-minute test watchdog,
    # 6-minute benchmark step, 10-minute floor gate that may re-run the
    # benchmarks up to twice on a noisy runner); this is the backstop.
    timeout-minutes: 25

    strategy:
      fail-fast: false # Don't cancel other matrix jobs when one fails
      matrix:
        include:
          - name: app
            path: .
          - name: BitLogger
            path: localPackages/BitLogger
          - name: BitFoundation
            path: localPackages/BitFoundation

    steps:
      - name: Checkout code
        uses: actions/checkout@v5

      # Use the Xcode-bundled Swift toolchain: it always matches the SDK on
      # the runner image. A standalone swift.org toolchain (setup-swift) broke
      # whenever the image's Xcode moved ahead of it ("this SDK is not
      # supported by the compiler").
      - name: Note toolchain version (cache key)
        id: swift-version
        run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"

      - name: Cache build artifacts
        uses: actions/cache@v4
        with:
          path: ${{ matrix.path }}/.build
          key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
          restore-keys: |
            ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
            ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-

      - name: Build tests
        # Built separately so the hang watchdog below times only test
        # execution: a cold-cache coverage build on a slow runner can
        # legitimately take several minutes, and is already bounded by the
        # 15-minute job timeout.
        run: swift build --build-tests --enable-code-coverage --package-path ${{ matrix.path }}

      - name: Run Tests
        # Perf benchmarks are excluded here and run in their own serial step
        # below: measuring while parallel test processes contend for cores
        # produces noisy numbers, and the XCTest measure machinery has hung
        # intermittently under parallel workers on loaded runners. Excluded
        # via --skip (not just the env guard): every app run since the
        # baselines landed timed out at the 15-minute job limit with the
        # baseline tests dispatched into the parallel phase.
        #
        # The watchdog samples any still-running test processes after 5
        # minutes (the suite passes in seconds when healthy; the build is
        # done by this step) and kills the run, so a hang fails fast with
        # stacks in the log instead of a silent timeout.
        env:
          BITCHAT_SKIP_PERF_BASELINES: "1"
        run: |
          swift test --skip-build --parallel --quiet --enable-code-coverage \
            --skip PerformanceBaselineTests \
            --package-path ${{ matrix.path }} &
          test_pid=$!
          (
            sleep 300
            if kill -0 "$test_pid" 2>/dev/null; then
              echo "::group::Tests still running after 5 minutes — sampling before kill"
              for pid in $(pgrep -if 'swiftpm-testing|xctest|PackageTests' || true); do
                echo "--- sample of pid $pid ---"
                sample "$pid" 5 2>/dev/null || true
              done
              echo "::endgroup::"
              pkill -KILL -P "$test_pid" 2>/dev/null || true
              kill -KILL "$test_pid" 2>/dev/null || true
            fi
          ) &
          watchdog_pid=$!
          wait "$test_pid" && status=0 || status=$?
          kill "$watchdog_pid" 2>/dev/null || true
          exit "$status"

      # Read coverage before the serial benchmark command below rebuilds the
      # test binary without instrumentation. Reporting against that newer
      # binary makes llvm-cov reject the profile as out of date.
      # Informational only: there is deliberately no percentage threshold, but
      # a broken/missing report is a CI configuration error and must be visible.
      - name: Coverage summary
        run: |
          BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
          PROF="$BIN_PATH/codecov/default.profdata"
          XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
          BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
          if [ ! -f "$PROF" ] || [ ! -f "$BINARY" ]; then
            echo "::error::Coverage profile or test binary is missing"
            exit 1
          fi
          xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
            -ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)'

      # Benchmarks run serially on an otherwise idle runner for stable
      # numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
      - name: Run performance benchmarks (serial)
        if: matrix.name == 'app'
        timeout-minutes: 6
        env:
          BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log
        run: swift test --quiet --filter PerformanceBaselineTests

      # Order-of-magnitude performance regression gate. Floors are deliberately
      # generous (see bitchatTests/Performance/perf-floors.json) so this
      # catches algorithmic regressions, never runner variance. If a metric
      # still lands below floor (a saturated runner can dip one), the script
      # re-runs the benchmarks — appending to the same log and keeping each
      # benchmark's best value per metric — so noise clears on retry while a
      # real regression fails every attempt. Floors are never lowered by this.
      - name: Performance floor gate
        if: matrix.name == 'app'
        timeout-minutes: 10
        run: ./scripts/check-perf-floors.sh perf-output.log

  # SPM tests do not link the shipping app targets. This job covers the
  # iOS-conditional paths and both universal Release link configurations.
  ios-build:
    name: Build Release apps (universal)
    runs-on: macos-latest
    timeout-minutes: 15

    steps:
      - name: Checkout code
        uses: actions/checkout@v5

      - name: Check clean recipe safety
        run: bash scripts/check-just-clean-safety.sh

      - name: Build iOS (simulator, no signing)
        # Build both simulator architectures so CI validates every vendored
        # Arti simulator slice and the configuration that ships.
        run: |
          set -o pipefail
          xcodebuild -project bitchat.xcodeproj \
            -scheme "bitchat (iOS)" \
            -configuration Release \
            -sdk iphonesimulator \
            -destination 'generic/platform=iOS Simulator' \
            ARCHS='arm64 x86_64' \
            ONLY_ACTIVE_ARCH=NO \
            CODE_SIGNING_ALLOWED=NO \
            build

      - name: Build macOS (universal, no signing)
        run: |
          set -o pipefail
          xcodebuild -project bitchat.xcodeproj \
            -scheme "bitchat (macOS)" \
            -configuration Release \
            -destination 'generic/platform=macOS' \
            ARCHS='arm64 x86_64' \
            ONLY_ACTIVE_ARCH=NO \
            CODE_SIGNING_ALLOWED=NO \
            build

  # The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth
  # conditional tests. Build the shared iOS test target and run it on the first
  # available iPhone simulator from the runner image instead of hard-coding a
  # model that changes when GitHub updates Xcode. The suite intentionally runs
  # in one test runner: a number of integration tests exercise process-global
  # stores and notification centers, so overlapping workers can corrupt each
  # other's fixtures and turn sub-second tests into multi-minute timeouts.
  ios-tests:
    name: Run iOS simulator tests
    runs-on: macos-latest
    timeout-minutes: 20

    steps:
      - name: Checkout code
        uses: actions/checkout@v5

      - name: Select available iPhone simulator
        id: destination
        run: |
          destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations)
          destination_id=$(awk -F'id:' '
            /platform:iOS Simulator/ && /name:iPhone/ && !found {
              value=$2
              sub(/,.*/, "", value)
              gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
              print value
              found=1
            }
          ' <<< "$destinations")
          if [ -z "$destination_id" ]; then
            echo "::error::No available iPhone simulator destination found"
            exit 1
          fi
          echo "id=$destination_id" >> "$GITHUB_OUTPUT"

      - name: Run iOS tests
        run: |
          set -o pipefail
          xcodebuild -project bitchat.xcodeproj \
            -scheme "bitchat (iOS)" \
            -sdk iphonesimulator \
            -destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \
            -parallel-testing-enabled NO \
            CODE_SIGNING_ALLOWED=NO \
            test

  # Advisory only: SwiftLint reports style violations without ever failing the
  # build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
  # it can never break the documented xcodebuild path or block a merge.
  lint:
    name: SwiftLint (advisory)
    runs-on: ubuntu-latest
    timeout-minutes: 15
    # This job runs a third-party container image, so give it the least
    # privilege we can: a read-only token, and no credentials left in the
    # checkout for the container to find.
    permissions:
      contents: read
    container:
      # Tag for readability, digest for immutability (tags can be repointed).
      # Bump both together, deliberately — never a floating tag.
      image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
    continue-on-error: true
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: false
      - name: Run SwiftLint
        run: swiftlint lint --reporter github-actions-logging
