name: CI

on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ci-${{ github.event.pull_request.number || github.sha }}
  cancel-in-progress: true

jobs:
  # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
  # Lint and format always run. Fail-safe: if detection fails, run everything.
  docs-scope:
    runs-on: ubuntu-latest
    outputs:
      docs_only: ${{ steps.check.outputs.docs_only }}
      docs_changed: ${{ steps.check.outputs.docs_changed }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          submodules: false

      - name: Detect docs-only changes
        id: check
        uses: ./.github/actions/detect-docs-changes

  # Detect which heavy areas are touched so PRs can skip unrelated expensive jobs.
  # Push to main keeps broad coverage.
  changed-scope:
    needs: [docs-scope]
    if: needs.docs-scope.outputs.docs_only != 'true'
    runs-on: ubuntu-latest
    outputs:
      run_node: ${{ steps.scope.outputs.run_node }}
      run_macos: ${{ steps.scope.outputs.run_macos }}
      run_android: ${{ steps.scope.outputs.run_android }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          submodules: false

      - name: Detect changed scopes
        id: scope
        shell: bash
        run: |
          set -euo pipefail

          if [ "${{ github.event_name }}" = "push" ]; then
            BASE="${{ github.event.before }}"
          else
            BASE="${{ github.event.pull_request.base.sha }}"
          fi

          CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
          if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
            # Fail-safe: run broad checks if detection fails.
            echo "run_node=true" >> "$GITHUB_OUTPUT"
            echo "run_macos=true" >> "$GITHUB_OUTPUT"
            echo "run_android=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          run_node=false
          run_macos=false
          run_android=false
          has_non_docs=false
          has_non_native_non_docs=false

          while IFS= read -r path; do
            [ -z "$path" ] && continue
            case "$path" in
              docs/*|*.md|*.mdx)
                continue
                ;;
              *)
                has_non_docs=true
                ;;
            esac

            case "$path" in
              # Generated protocol models are already covered by protocol:check and
              # should not force the full native macOS lane.
              apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*)
                ;;
              apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
                run_macos=true
                ;;
            esac

            case "$path" in
              apps/android/*|apps/shared/*)
                run_android=true
                ;;
            esac

            case "$path" in
              src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc)
                run_node=true
                ;;
            esac

            case "$path" in
              apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml)
                ;;
              *)
                has_non_native_non_docs=true
                ;;
            esac
          done <<< "$CHANGED"

          # If there are non-doc files outside native app trees, keep Node checks enabled.
          if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then
            run_node=true
          fi

          echo "run_node=${run_node}" >> "$GITHUB_OUTPUT"
          echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT"
          echo "run_android=${run_android}" >> "$GITHUB_OUTPUT"

  # Build dist once for Node-relevant changes and share it with downstream jobs.
  build-artifacts:
    needs: [docs-scope, changed-scope, check]
    if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"

      - name: Build dist
        run: pnpm build

      - name: Upload dist artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist-build
          path: dist/
          retention-days: 1

  # Validate npm pack contents after build (only on push to main, not PRs).
  release-check:
    needs: [docs-scope, build-artifacts]
    if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true'
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"

      - name: Download dist artifact
        uses: actions/download-artifact@v4
        with:
          name: dist-build
          path: dist/

      - name: Check release contents
        run: pnpm release:check

  checks:
    needs: [docs-scope, changed-scope, check]
    if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
    runs-on: blacksmith-4vcpu-ubuntu-2404
    strategy:
      fail-fast: false
      matrix:
        include:
          - runtime: node
            task: test
            command: pnpm canvas:a2ui:bundle && pnpm test
          - runtime: node
            task: protocol
            command: pnpm protocol:check
          - runtime: bun
            task: test
            command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env

      - name: Configure vitest JSON reports
        if: matrix.task == 'test' && matrix.runtime == 'node'
        run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"

      - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
        run: ${{ matrix.command }}

      - name: Summarize slowest tests
        if: matrix.task == 'test' && matrix.runtime == 'node'
        run: |
          node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
          echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"

      - name: Upload vitest reports
        if: matrix.task == 'test' && matrix.runtime == 'node'
        uses: actions/upload-artifact@v4
        with:
          name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
          path: |
            ${{ env.OPENCLAW_VITEST_REPORT_DIR }}
            ${{ runner.temp }}/vitest-slowest.md

  # Types, lint, and format check.
  check:
    name: "check"
    needs: [docs-scope]
    if: needs.docs-scope.outputs.docs_only != 'true'
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env

      - name: Check types and lint and oxfmt
        run: pnpm check

  # Validate docs (format, lint, broken links) only when docs files changed.
  check-docs:
    needs: [docs-scope]
    if: needs.docs-scope.outputs.docs_changed == 'true'
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env

      - name: Check docs
        run: pnpm check:docs

  secrets:
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: false

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install detect-secrets
        run: |
          python -m pip install --upgrade pip
          python -m pip install detect-secrets==1.5.0

      - name: Detect secrets
        run: |
          if ! detect-secrets scan --baseline .secrets.baseline; then
            echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
            exit 1
          fi

  checks-windows:
    needs: [docs-scope, changed-scope, build-artifacts, check]
    if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
    runs-on: blacksmith-4vcpu-windows-2025
    env:
      NODE_OPTIONS: --max-old-space-size=4096
      # Keep total concurrency predictable on the 4 vCPU runner:
      # `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
      OPENCLAW_TEST_WORKERS: 2
    defaults:
      run:
        shell: bash
    strategy:
      fail-fast: false
      matrix:
        include:
          - runtime: node
            task: lint
            command: pnpm lint
          - runtime: node
            task: test
            command: pnpm canvas:a2ui:bundle && pnpm test
          - runtime: node
            task: protocol
            command: pnpm protocol:check
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: false

      - name: Try to exclude workspace from Windows Defender (best-effort)
        shell: pwsh
        run: |
          $cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
          if (-not $cmd) {
            Write-Host "Add-MpPreference not available, skipping Defender exclusions."
            exit 0
          }

          try {
            # Defender sometimes intercepts process spawning (vitest workers). If this fails
            # (eg hardened images), keep going and rely on worker limiting above.
            Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
            Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
            Write-Host "Defender exclusions applied."
          } catch {
            Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
          }

      - name: Download dist artifact (lint lane)
        if: matrix.task == 'lint'
        uses: actions/download-artifact@v4
        with:
          name: dist-build
          path: dist/

      - name: Verify dist artifact (lint lane)
        if: matrix.task == 'lint'
        run: |
          set -euo pipefail
          test -s dist/index.js
          test -s dist/plugin-sdk/index.js

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22.x
          check-latest: true

      - name: Setup pnpm + cache store
        uses: ./.github/actions/setup-pnpm-store-cache
        with:
          pnpm-version: "10.23.0"
          cache-key-suffix: "node22"

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Runtime versions
        run: |
          node -v
          npm -v
          bun -v
          pnpm -v

      - name: Capture node path
        run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"

      - name: Install dependencies
        env:
          CI: true
        run: |
          export PATH="$NODE_BIN:$PATH"
          which node
          node -v
          pnpm -v
          pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true

      - name: Configure vitest JSON reports
        if: matrix.task == 'test'
        run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"

      - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
        run: ${{ matrix.command }}

      - name: Summarize slowest tests
        if: matrix.task == 'test'
        run: |
          node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
          echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"

      - name: Upload vitest reports
        if: matrix.task == 'test'
        uses: actions/upload-artifact@v4
        with:
          name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
          path: |
            ${{ env.OPENCLAW_VITEST_REPORT_DIR }}
            ${{ runner.temp }}/vitest-slowest.md

  # Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
  # on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
  # running 4 separate jobs per PR (as before) starved the queue. One job
  # per PR allows 5 PRs to run macOS checks simultaneously.
  macos:
    needs: [docs-scope, changed-scope, check]
    if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
    runs-on: macos-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"

      # --- Run all checks sequentially (fast gates first) ---
      - name: TS tests (macOS)
        env:
          NODE_OPTIONS: --max-old-space-size=4096
        run: pnpm test

      # --- Xcode/Swift setup ---
      - name: Select Xcode 26.1
        run: |
          sudo xcode-select -s /Applications/Xcode_26.1.app
          xcodebuild -version

      - name: Install XcodeGen / SwiftLint / SwiftFormat
        run: brew install xcodegen swiftlint swiftformat

      - name: Show toolchain
        run: |
          sw_vers
          xcodebuild -version
          swift --version

      - name: Swift lint
        run: |
          swiftlint --config .swiftlint.yml
          swiftformat --lint apps/macos/Sources --config .swiftformat

      - name: Cache SwiftPM
        uses: actions/cache@v4
        with:
          path: ~/Library/Caches/org.swift.swiftpm
          key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
          restore-keys: |
            ${{ runner.os }}-swiftpm-

      - name: Swift build (release)
        run: |
          set -euo pipefail
          for attempt in 1 2 3; do
            if swift build --package-path apps/macos --configuration release; then
              exit 0
            fi
            echo "swift build failed (attempt $attempt/3). Retrying…"
            sleep $((attempt * 20))
          done
          exit 1

      - name: Swift test
        run: |
          set -euo pipefail
          for attempt in 1 2 3; do
            if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
              exit 0
            fi
            echo "swift test failed (attempt $attempt/3). Retrying…"
            sleep $((attempt * 20))
          done
          exit 1

  ios:
    if: false # ignore iOS in CI for now
    runs-on: macos-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: false

      - name: Select Xcode 26.1
        run: |
          sudo xcode-select -s /Applications/Xcode_26.1.app
          xcodebuild -version

      - name: Install XcodeGen
        run: brew install xcodegen

      - name: Install SwiftLint / SwiftFormat
        run: brew install swiftlint swiftformat

      - name: Show toolchain
        run: |
          sw_vers
          xcodebuild -version
          swift --version

      - name: Generate iOS project
        run: |
          cd apps/ios
          xcodegen generate

      - name: iOS tests
        run: |
          set -euo pipefail
          RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
          DEST_ID="$(
            python3 - <<'PY'
          import json
          import subprocess
          import sys
          import uuid

          def sh(args: list[str]) -> str:
            return subprocess.check_output(args, text=True).strip()

          # Prefer an already-created iPhone simulator if it exists.
          devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"]))
          candidates: list[tuple[str, str]] = []
          for runtime, devs in (devices.get("devices") or {}).items():
            for dev in devs or []:
              if not dev.get("isAvailable"):
                continue
              name = str(dev.get("name") or "")
              udid = str(dev.get("udid") or "")
              if not udid or not name.startswith("iPhone"):
                continue
              candidates.append((name, udid))

          candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0]))
          if candidates:
            print(candidates[0][1])
            sys.exit(0)

          # Otherwise, create one from the newest available iOS runtime.
          runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or []
          ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")]
          if not ios:
            print("No available iOS runtimes found.", file=sys.stderr)
            sys.exit(1)

          def version_key(rt: dict) -> tuple[int, ...]:
            parts: list[int] = []
            for p in str(rt.get("version") or "0").split("."):
              try:
                parts.append(int(p))
              except ValueError:
                parts.append(0)
            return tuple(parts)

          ios.sort(key=version_key, reverse=True)
          runtime = ios[0]
          runtime_id = str(runtime.get("identifier") or "")
          if not runtime_id:
            print("Missing iOS runtime identifier.", file=sys.stderr)
            sys.exit(1)

          supported = runtime.get("supportedDeviceTypes") or []
          iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"]
          if not iphones:
            print("No iPhone device types for iOS runtime.", file=sys.stderr)
            sys.exit(1)

          iphones.sort(
            key=lambda dt: (
              0 if "iPhone 16" in str(dt.get("name") or "") else 1,
              str(dt.get("name") or ""),
            )
          )
          device_type_id = str(iphones[0].get("identifier") or "")
          if not device_type_id:
            print("Missing iPhone device type identifier.", file=sys.stderr)
            sys.exit(1)

          sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}"
          udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id])
          if not udid:
            print("Failed to create iPhone simulator.", file=sys.stderr)
            sys.exit(1)
          print(udid)
          PY
          )"
          echo "Using iOS Simulator id: $DEST_ID"
          xcodebuild test \
            -project apps/ios/Clawdis.xcodeproj \
            -scheme Clawdis \
            -destination "platform=iOS Simulator,id=$DEST_ID" \
            -resultBundlePath "$RESULT_BUNDLE_PATH" \
            -enableCodeCoverage YES

      - name: iOS coverage summary
        run: |
          set -euo pipefail
          RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
          xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH"

      - name: iOS coverage gate (43%)
        run: |
          set -euo pipefail
          RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
          RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY'
          import json
          import os
          import subprocess
          import sys

          target_name = "Clawdis.app"
          minimum = 0.43

          report = json.loads(
            subprocess.check_output(
              ["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]],
              text=True,
            )
          )

          target_coverage = None
          for target in report.get("targets", []):
            if target.get("name") == target_name:
              target_coverage = float(target["lineCoverage"])
              break

          if target_coverage is None:
            print(f"Could not find coverage for target: {target_name}")
            sys.exit(1)

          print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)")
          if target_coverage + 1e-12 < minimum:
            sys.exit(1)
          PY

  android:
    needs: [docs-scope, changed-scope, check]
    if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
    runs-on: blacksmith-4vcpu-ubuntu-2404
    strategy:
      fail-fast: false
      matrix:
        include:
          - task: test
            command: ./gradlew --no-daemon :app:testDebugUnitTest
          - task: build
            command: ./gradlew --no-daemon :app:assembleDebug
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: false

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 21

      - name: Setup Android SDK
        uses: android-actions/setup-android@v3
        with:
          accept-android-sdk-licenses: false

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4
        with:
          gradle-version: 8.11.1

      - name: Install Android SDK packages
        run: |
          yes | sdkmanager --licenses >/dev/null
          sdkmanager --install \
            "platform-tools" \
            "platforms;android-36" \
            "build-tools;36.0.0"

      - name: Run Android ${{ matrix.task }}
        working-directory: apps/android
        run: ${{ matrix.command }}
